Răsfoiți Sursa

refactor(effect): move account auth question modules

Kit Langton 1 lună în urmă
părinte
comite
c687262c59

+ 355 - 0
packages/opencode/src/account/effect.ts

@@ -0,0 +1,355 @@
+import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+
+import { withTransientReadRetry } from "@/util/effect-http-client"
+import { AccountRepo, type AccountRow } from "./repo"
+import {
+  type AccountError as SchemaError,
+  AccessToken as SchemaAccessToken,
+  Account as SchemaAccount,
+  AccountID as SchemaAccountID,
+  DeviceCode as SchemaDeviceCode,
+  RefreshToken as SchemaRefreshToken,
+  AccountServiceError as SchemaServiceError,
+  Login as SchemaLogin,
+  Org as SchemaOrg,
+  OrgID as SchemaOrgID,
+  PollDenied as SchemaPollDenied,
+  PollError as SchemaPollError,
+  PollExpired as SchemaPollExpired,
+  PollPending as SchemaPollPending,
+  type PollResult as SchemaPollResult,
+  PollSlow as SchemaPollSlow,
+  PollSuccess as SchemaPollSuccess,
+  UserCode as SchemaUserCode,
+} from "./schema"
+
+export namespace AccountEffect {
+  export type Error = SchemaError
+
+  const AccessToken = SchemaAccessToken
+  type AccessToken = SchemaAccessToken
+  const Account = SchemaAccount
+  type Account = SchemaAccount
+  const AccountID = SchemaAccountID
+  type AccountID = SchemaAccountID
+  const DeviceCode = SchemaDeviceCode
+  type DeviceCode = SchemaDeviceCode
+  const RefreshToken = SchemaRefreshToken
+  type RefreshToken = SchemaRefreshToken
+  const Login = SchemaLogin
+  type Login = SchemaLogin
+  const Org = SchemaOrg
+  type Org = SchemaOrg
+  const OrgID = SchemaOrgID
+  type OrgID = SchemaOrgID
+  const PollDenied = SchemaPollDenied
+  const PollError = SchemaPollError
+  const PollExpired = SchemaPollExpired
+  const PollPending = SchemaPollPending
+  const PollSlow = SchemaPollSlow
+  const PollSuccess = SchemaPollSuccess
+  const UserCode = SchemaUserCode
+  type PollResult = SchemaPollResult
+
+  export type AccountOrgs = {
+    account: Account
+    orgs: readonly Org[]
+  }
+
+  class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
+    config: Schema.Record(Schema.String, Schema.Json),
+  }) {}
+
+  const DurationFromSeconds = Schema.Number.pipe(
+    Schema.decodeTo(Schema.Duration, {
+      decode: SchemaGetter.transform((n) => Duration.seconds(n)),
+      encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
+    }),
+  )
+
+  class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
+    access_token: AccessToken,
+    refresh_token: RefreshToken,
+    expires_in: DurationFromSeconds,
+  }) {}
+
+  class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
+    device_code: DeviceCode,
+    user_code: UserCode,
+    verification_uri_complete: Schema.String,
+    expires_in: DurationFromSeconds,
+    interval: DurationFromSeconds,
+  }) {}
+
+  class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
+    access_token: AccessToken,
+    refresh_token: RefreshToken,
+    token_type: Schema.Literal("Bearer"),
+    expires_in: DurationFromSeconds,
+  }) {}
+
+  class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
+    error: Schema.String,
+    error_description: Schema.String,
+  }) {
+    toPollResult(): PollResult {
+      if (this.error === "authorization_pending") return new PollPending()
+      if (this.error === "slow_down") return new PollSlow()
+      if (this.error === "expired_token") return new PollExpired()
+      if (this.error === "access_denied") return new PollDenied()
+      return new PollError({ cause: this.error })
+    }
+  }
+
+  const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
+
+  class User extends Schema.Class<User>("User")({
+    id: AccountID,
+    email: Schema.String,
+  }) {}
+
+  class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
+
+  class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
+    grant_type: Schema.String,
+    device_code: DeviceCode,
+    client_id: Schema.String,
+  }) {}
+
+  class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
+    grant_type: Schema.String,
+    refresh_token: RefreshToken,
+    client_id: Schema.String,
+  }) {}
+
+  const client_id = "opencode-cli"
+
+  const map =
+    (message = "Account service operation failed") =>
+    <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, SchemaServiceError, R> =>
+      effect.pipe(
+        Effect.mapError((cause) =>
+          cause instanceof SchemaServiceError ? cause : new SchemaServiceError({ message, cause }),
+        ),
+      )
+
+  export interface Interface {
+    readonly active: () => Effect.Effect<Option.Option<Account>, Error>
+    readonly list: () => Effect.Effect<Account[], Error>
+    readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], Error>
+    readonly remove: (accountID: AccountID) => Effect.Effect<void, Error>
+    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, Error>
+    readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], Error>
+    readonly config: (accountID: AccountID, orgID: OrgID) => Effect.Effect<Option.Option<Record<string, unknown>>, Error>
+    readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, Error>
+    readonly login: (url: string) => Effect.Effect<Login, Error>
+    readonly poll: (input: Login) => Effect.Effect<PollResult, Error>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
+
+  export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const repo = yield* AccountRepo
+      const http = yield* HttpClient.HttpClient
+      const httpRead = withTransientReadRetry(http)
+      const httpOk = HttpClient.filterStatusOk(http)
+      const httpReadOk = HttpClient.filterStatusOk(httpRead)
+
+      const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
+        httpRead.execute(request).pipe(map("HTTP request failed"))
+
+      const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
+        httpReadOk.execute(request).pipe(map("HTTP request failed"))
+
+      const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+        request.pipe(
+          Effect.flatMap((req) => httpOk.execute(req)),
+          map("HTTP request failed"),
+        )
+
+      const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
+        const now = yield* Clock.currentTimeMillis
+        if (row.token_expiry && row.token_expiry > now) return row.access_token
+
+        const response = yield* executeEffectOk(
+          HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
+              new TokenRefreshRequest({
+                grant_type: "refresh_token",
+                refresh_token: row.refresh_token,
+                client_id,
+              }),
+            ),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
+          map("Failed to decode response"),
+        )
+
+        const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
+
+        yield* repo.persistToken({
+          accountID: row.id,
+          accessToken: parsed.access_token,
+          refreshToken: parsed.refresh_token,
+          expiry,
+        })
+
+        return parsed.access_token
+      })
+
+      const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
+        const maybe = yield* repo.getRow(accountID)
+        if (Option.isNone(maybe)) return Option.none()
+
+        const account = maybe.value
+        const accessToken = yield* resolveToken(account)
+        return Option.some({ account, accessToken })
+      })
+
+      const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
+        const response = yield* executeReadOk(
+          HttpClientRequest.get(`${url}/api/orgs`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+          ),
+        )
+
+        return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
+          map("Failed to decode response"),
+        )
+      })
+
+      const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
+        const response = yield* executeReadOk(
+          HttpClientRequest.get(`${url}/api/user`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+          ),
+        )
+
+        return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(map("Failed to decode response"))
+      })
+
+      const token = Effect.fn("Account.token")((accountID: AccountID) =>
+        resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
+      )
+
+      const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
+        const resolved = yield* resolveAccess(accountID)
+        if (Option.isNone(resolved)) return []
+
+        return yield* fetchOrgs(resolved.value.account.url, resolved.value.accessToken)
+      })
+
+      const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
+        const accounts = yield* repo.list()
+        const [errors, results] = yield* Effect.partition(
+          accounts,
+          (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
+          { concurrency: 3 },
+        )
+        for (const err of errors) {
+          yield* Effect.logWarning("failed to fetch orgs for account").pipe(Effect.annotateLogs({ error: String(err) }))
+        }
+        return results
+      })
+
+      const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
+        const resolved = yield* resolveAccess(accountID)
+        if (Option.isNone(resolved)) return Option.none()
+
+        const response = yield* executeRead(
+          HttpClientRequest.get(`${resolved.value.account.url}/api/config`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(resolved.value.accessToken),
+            HttpClientRequest.setHeaders({ "x-org-id": orgID }),
+          ),
+        )
+
+        if (response.status === 404) return Option.none()
+
+        const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(map())
+        const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(map("Failed to decode response"))
+        return Option.some(parsed.config)
+      })
+
+      const login = Effect.fn("Account.login")(function* (server: string) {
+        const response = yield* executeEffectOk(
+          HttpClientRequest.post(`${server}/auth/device/code`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id })),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(map("Failed to decode response"))
+        return new Login({
+          code: parsed.device_code,
+          user: parsed.user_code,
+          url: `${server}${parsed.verification_uri_complete}`,
+          server,
+          expiry: parsed.expires_in,
+          interval: parsed.interval,
+        })
+      })
+
+      const poll = Effect.fn("Account.poll")(function* (input: Login) {
+        const response = yield* executeEffectOk(
+          HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
+              new DeviceTokenRequest({
+                grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+                device_code: input.code,
+                client_id,
+              }),
+            ),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(map("Failed to decode response"))
+        if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
+
+        const [account, remoteOrgs] = yield* Effect.all(
+          [fetchUser(input.server, parsed.access_token), fetchOrgs(input.server, parsed.access_token)],
+          { concurrency: 2 },
+        )
+
+        const first = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
+        const expiry = (yield* Clock.currentTimeMillis) + Duration.toMillis(parsed.expires_in)
+
+        yield* repo.persistAccount({
+          id: account.id,
+          email: account.email,
+          url: input.server,
+          accessToken: parsed.access_token,
+          refreshToken: parsed.refresh_token,
+          expiry,
+          orgID: first,
+        })
+
+        return new PollSuccess({ email: account.email })
+      })
+
+      return Service.of({
+        active: repo.active,
+        list: repo.list,
+        orgsByAccount,
+        remove: repo.remove,
+        use: repo.use,
+        orgs,
+        config,
+        token,
+        login,
+        poll,
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
+}

+ 17 - 11
packages/opencode/src/account/index.ts

@@ -1,27 +1,33 @@
 import { Effect, Option } from "effect"
 
+import { AccountEffect } from "./effect"
 import {
+  AccessToken as Token,
   Account as AccountSchema,
   type AccountError,
-  type AccessToken,
-  AccountID,
-  AccountService,
-  OrgID,
-} from "./service"
-
-export { AccessToken, AccountID, OrgID } from "./service"
+  AccountID as ID,
+  OrgID as Org,
+} from "./schema"
 
 import { runtime } from "@/effect/runtime"
 
-function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
-  return runtime.runSync(AccountService.use(f))
+export { AccessToken, AccountID, OrgID } from "./schema"
+
+function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountEffect.Error>) {
+  return runtime.runSync(AccountEffect.Service.use(f))
 }
 
-function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
-  return runtime.runPromise(AccountService.use(f))
+function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runPromise(AccountEffect.Service.use(f))
 }
 
 export namespace Account {
+  export const AccessToken = Token
+  export type AccessToken = Token
+  export const AccountID = ID
+  export type AccountID = ID
+  export const OrgID = Org
+  export type OrgID = Org
   export const Account = AccountSchema
   export type Account = AccountSchema
 

+ 0 - 359
packages/opencode/src/account/service.ts

@@ -1,359 +0,0 @@
-import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
-import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-
-import { withTransientReadRetry } from "@/util/effect-http-client"
-import { AccountRepo, type AccountRow } from "./repo"
-import {
-  type AccountError,
-  AccessToken,
-  Account,
-  AccountID,
-  DeviceCode,
-  RefreshToken,
-  AccountServiceError,
-  Login,
-  Org,
-  OrgID,
-  PollDenied,
-  PollError,
-  PollExpired,
-  PollPending,
-  type PollResult,
-  PollSlow,
-  PollSuccess,
-  UserCode,
-} from "./schema"
-
-export * from "./schema"
-
-export type AccountOrgs = {
-  account: Account
-  orgs: readonly Org[]
-}
-
-class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
-  config: Schema.Record(Schema.String, Schema.Json),
-}) {}
-
-const DurationFromSeconds = Schema.Number.pipe(
-  Schema.decodeTo(Schema.Duration, {
-    decode: SchemaGetter.transform((n) => Duration.seconds(n)),
-    encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
-  }),
-)
-
-class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
-  access_token: AccessToken,
-  refresh_token: RefreshToken,
-  expires_in: DurationFromSeconds,
-}) {}
-
-class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
-  device_code: DeviceCode,
-  user_code: UserCode,
-  verification_uri_complete: Schema.String,
-  expires_in: DurationFromSeconds,
-  interval: DurationFromSeconds,
-}) {}
-
-class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
-  access_token: AccessToken,
-  refresh_token: RefreshToken,
-  token_type: Schema.Literal("Bearer"),
-  expires_in: DurationFromSeconds,
-}) {}
-
-class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
-  error: Schema.String,
-  error_description: Schema.String,
-}) {
-  toPollResult(): PollResult {
-    if (this.error === "authorization_pending") return new PollPending()
-    if (this.error === "slow_down") return new PollSlow()
-    if (this.error === "expired_token") return new PollExpired()
-    if (this.error === "access_denied") return new PollDenied()
-    return new PollError({ cause: this.error })
-  }
-}
-
-const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
-
-class User extends Schema.Class<User>("User")({
-  id: AccountID,
-  email: Schema.String,
-}) {}
-
-class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
-
-class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
-  grant_type: Schema.String,
-  device_code: DeviceCode,
-  client_id: Schema.String,
-}) {}
-
-class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
-  grant_type: Schema.String,
-  refresh_token: RefreshToken,
-  client_id: Schema.String,
-}) {}
-
-const clientId = "opencode-cli"
-
-const mapAccountServiceError =
-  (message = "Account service operation failed") =>
-  <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
-    effect.pipe(
-      Effect.mapError((cause) =>
-        cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
-      ),
-    )
-
-export namespace AccountService {
-  export interface Service {
-    readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
-    readonly list: () => Effect.Effect<Account[], AccountError>
-    readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
-    readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
-    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
-    readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
-    readonly config: (
-      accountID: AccountID,
-      orgID: OrgID,
-    ) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
-    readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
-    readonly login: (url: string) => Effect.Effect<Login, AccountError>
-    readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
-  }
-}
-
-export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
-  static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
-    AccountService,
-    Effect.gen(function* () {
-      const repo = yield* AccountRepo
-      const http = yield* HttpClient.HttpClient
-      const httpRead = withTransientReadRetry(http)
-      const httpOk = HttpClient.filterStatusOk(http)
-      const httpReadOk = HttpClient.filterStatusOk(httpRead)
-
-      const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
-        httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
-
-      const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
-        httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
-
-      const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
-        request.pipe(
-          Effect.flatMap((req) => httpOk.execute(req)),
-          mapAccountServiceError("HTTP request failed"),
-        )
-
-      // Returns a usable access token for a stored account row, refreshing and
-      // persisting it when the cached token has expired.
-      const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
-        const now = yield* Clock.currentTimeMillis
-        if (row.token_expiry && row.token_expiry > now) return row.access_token
-
-        const response = yield* executeEffectOk(
-          HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
-              new TokenRefreshRequest({
-                grant_type: "refresh_token",
-                refresh_token: row.refresh_token,
-                client_id: clientId,
-              }),
-            ),
-          ),
-        )
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-
-        const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
-
-        yield* repo.persistToken({
-          accountID: row.id,
-          accessToken: parsed.access_token,
-          refreshToken: parsed.refresh_token,
-          expiry,
-        })
-
-        return parsed.access_token
-      })
-
-      const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
-        const maybeAccount = yield* repo.getRow(accountID)
-        if (Option.isNone(maybeAccount)) return Option.none()
-
-        const account = maybeAccount.value
-        const accessToken = yield* resolveToken(account)
-        return Option.some({ account, accessToken })
-      })
-
-      const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
-        const response = yield* executeReadOk(
-          HttpClientRequest.get(`${url}/api/orgs`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.bearerToken(accessToken),
-          ),
-        )
-
-        return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-      })
-
-      const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
-        const response = yield* executeReadOk(
-          HttpClientRequest.get(`${url}/api/user`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.bearerToken(accessToken),
-          ),
-        )
-
-        return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-      })
-
-      const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
-        resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
-      )
-
-      const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
-        const accounts = yield* repo.list()
-        const [errors, results] = yield* Effect.partition(
-          accounts,
-          (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
-          { concurrency: 3 },
-        )
-        for (const error of errors) {
-          yield* Effect.logWarning("failed to fetch orgs for account").pipe(
-            Effect.annotateLogs({ error: String(error) }),
-          )
-        }
-        return results
-      })
-
-      const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
-        const resolved = yield* resolveAccess(accountID)
-        if (Option.isNone(resolved)) return []
-
-        const { account, accessToken } = resolved.value
-
-        return yield* fetchOrgs(account.url, accessToken)
-      })
-
-      const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
-        const resolved = yield* resolveAccess(accountID)
-        if (Option.isNone(resolved)) return Option.none()
-
-        const { account, accessToken } = resolved.value
-
-        const response = yield* executeRead(
-          HttpClientRequest.get(`${account.url}/api/config`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.bearerToken(accessToken),
-            HttpClientRequest.setHeaders({ "x-org-id": orgID }),
-          ),
-        )
-
-        if (response.status === 404) return Option.none()
-
-        const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-        return Option.some(parsed.config)
-      })
-
-      const login = Effect.fn("AccountService.login")(function* (server: string) {
-        const response = yield* executeEffectOk(
-          HttpClientRequest.post(`${server}/auth/device/code`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
-          ),
-        )
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-        return new Login({
-          code: parsed.device_code,
-          user: parsed.user_code,
-          url: `${server}${parsed.verification_uri_complete}`,
-          server,
-          expiry: parsed.expires_in,
-          interval: parsed.interval,
-        })
-      })
-
-      const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
-        const response = yield* executeEffectOk(
-          HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
-            HttpClientRequest.acceptJson,
-            HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
-              new DeviceTokenRequest({
-                grant_type: "urn:ietf:params:oauth:grant-type:device_code",
-                device_code: input.code,
-                client_id: clientId,
-              }),
-            ),
-          ),
-        )
-
-        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
-          mapAccountServiceError("Failed to decode response"),
-        )
-
-        if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
-        const accessToken = parsed.access_token
-
-        const user = fetchUser(input.server, accessToken)
-        const orgs = fetchOrgs(input.server, accessToken)
-
-        const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
-
-        // TODO: When there are multiple orgs, let the user choose
-        const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
-
-        const now = yield* Clock.currentTimeMillis
-        const expiry = now + Duration.toMillis(parsed.expires_in)
-        const refreshToken = parsed.refresh_token
-
-        yield* repo.persistAccount({
-          id: account.id,
-          email: account.email,
-          url: input.server,
-          accessToken,
-          refreshToken,
-          expiry,
-          orgID: firstOrgID,
-        })
-
-        return new PollSuccess({ email: account.email })
-      })
-
-      return AccountService.of({
-        active: repo.active,
-        list: repo.list,
-        orgsByAccount,
-        remove: repo.remove,
-        use: repo.use,
-        orgs,
-        config,
-        token,
-        login,
-        poll,
-      })
-    }),
-  )
-
-  static readonly defaultLayer = AccountService.layer.pipe(
-    Layer.provide(AccountRepo.layer),
-    Layer.provide(FetchHttpClient.layer),
-  )
-}

+ 98 - 0
packages/opencode/src/auth/effect.ts

@@ -0,0 +1,98 @@
+import path from "path"
+import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
+import { Global } from "../global"
+import { Filesystem } from "../util/filesystem"
+
+export namespace AuthEffect {
+  export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
+
+  export class Oauth extends Schema.Class<Oauth>("OAuth")({
+    type: Schema.Literal("oauth"),
+    refresh: Schema.String,
+    access: Schema.String,
+    expires: Schema.Number,
+    accountId: Schema.optional(Schema.String),
+    enterpriseUrl: Schema.optional(Schema.String),
+  }) {}
+
+  export class ApiAuth extends Schema.Class<ApiAuth>("ApiAuth")({
+    type: Schema.Literal("api"),
+    key: Schema.String,
+  }) {}
+
+  export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
+    type: Schema.Literal("wellknown"),
+    key: Schema.String,
+    token: Schema.String,
+  }) {}
+
+  export const Info = Schema.Union([Oauth, ApiAuth, WellKnown])
+  export type Info = Schema.Schema.Type<typeof Info>
+
+  export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
+    message: Schema.String,
+    cause: Schema.optional(Schema.Defect),
+  }) {}
+
+  export type Error = AuthServiceError
+
+  const file = path.join(Global.Path.data, "auth.json")
+
+  const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
+
+  export interface Interface {
+    readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
+    readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
+    readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
+    readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const decode = Schema.decodeUnknownOption(Info)
+
+      const all = Effect.fn("Auth.all")(() =>
+        Effect.tryPromise({
+          try: async () => {
+            const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
+            return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
+          },
+          catch: fail("Failed to read auth data"),
+        }),
+      )
+
+      const get = Effect.fn("Auth.get")(function* (providerID: string) {
+        return (yield* all())[providerID]
+      })
+
+      const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
+        const norm = key.replace(/\/+$/, "")
+        const data = yield* all()
+        if (norm !== key) delete data[key]
+        delete data[norm + "/"]
+        yield* Effect.tryPromise({
+          try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
+          catch: fail("Failed to write auth data"),
+        })
+      })
+
+      const remove = Effect.fn("Auth.remove")(function* (key: string) {
+        const norm = key.replace(/\/+$/, "")
+        const data = yield* all()
+        delete data[key]
+        delete data[norm]
+        yield* Effect.tryPromise({
+          try: () => Filesystem.writeJson(file, data, 0o600),
+          catch: fail("Failed to write auth data"),
+        })
+      })
+
+      return Service.of({ get, all, set, remove })
+    }),
+  )
+
+  export const defaultLayer = layer
+}

+ 4 - 4
packages/opencode/src/auth/index.ts

@@ -1,12 +1,12 @@
 import { Effect } from "effect"
 import z from "zod"
 import { runtime } from "@/effect/runtime"
-import * as S from "./service"
+import * as S from "./effect"
 
-export { OAUTH_DUMMY_KEY } from "./service"
+export const OAUTH_DUMMY_KEY = S.AuthEffect.OAUTH_DUMMY_KEY
 
-function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
-  return runtime.runPromise(S.AuthService.use(f))
+function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthEffect.Error>) {
+  return runtime.runPromise(S.AuthEffect.Service.use(f))
 }
 
 export namespace Auth {

+ 0 - 101
packages/opencode/src/auth/service.ts

@@ -1,101 +0,0 @@
-import path from "path"
-import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
-import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
-
-export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
-
-export class Oauth extends Schema.Class<Oauth>("OAuth")({
-  type: Schema.Literal("oauth"),
-  refresh: Schema.String,
-  access: Schema.String,
-  expires: Schema.Number,
-  accountId: Schema.optional(Schema.String),
-  enterpriseUrl: Schema.optional(Schema.String),
-}) {}
-
-export class Api extends Schema.Class<Api>("ApiAuth")({
-  type: Schema.Literal("api"),
-  key: Schema.String,
-}) {}
-
-export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
-  type: Schema.Literal("wellknown"),
-  key: Schema.String,
-  token: Schema.String,
-}) {}
-
-export const Info = Schema.Union([Oauth, Api, WellKnown])
-export type Info = Schema.Schema.Type<typeof Info>
-
-export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
-  message: Schema.String,
-  cause: Schema.optional(Schema.Defect),
-}) {}
-
-const file = path.join(Global.Path.data, "auth.json")
-
-const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
-
-export namespace AuthService {
-  export interface Service {
-    readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
-    readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
-    readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
-    readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
-  }
-}
-
-export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
-  static readonly layer = Layer.effect(
-    AuthService,
-    Effect.gen(function* () {
-      const decode = Schema.decodeUnknownOption(Info)
-
-      const all = Effect.fn("AuthService.all")(() =>
-        Effect.tryPromise({
-          try: async () => {
-            const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
-            return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
-          },
-          catch: fail("Failed to read auth data"),
-        }),
-      )
-
-      const get = Effect.fn("AuthService.get")(function* (providerID: string) {
-        return (yield* all())[providerID]
-      })
-
-      const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
-        const norm = key.replace(/\/+$/, "")
-        const data = yield* all()
-        if (norm !== key) delete data[key]
-        delete data[norm + "/"]
-        yield* Effect.tryPromise({
-          try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
-          catch: fail("Failed to write auth data"),
-        })
-      })
-
-      const remove = Effect.fn("AuthService.remove")(function* (key: string) {
-        const norm = key.replace(/\/+$/, "")
-        const data = yield* all()
-        delete data[key]
-        delete data[norm]
-        yield* Effect.tryPromise({
-          try: () => Filesystem.writeJson(file, data, 0o600),
-          catch: fail("Failed to write auth data"),
-        })
-      })
-
-      return AuthService.of({
-        get,
-        all,
-        set,
-        remove,
-      })
-    }),
-  )
-
-  static readonly defaultLayer = AuthService.layer
-}

+ 6 - 6
packages/opencode/src/cli/cmd/account.ts

@@ -2,8 +2,8 @@ import { cmd } from "./cmd"
 import { Duration, Effect, Match, Option } from "effect"
 import { UI } from "../ui"
 import { runtime } from "@/effect/runtime"
-import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
-import { type AccountError } from "@/account/schema"
+import { AccountEffect } from "@/account/effect"
+import { type AccountError, AccountID, OrgID, PollExpired, type PollResult } from "@/account/schema"
 import * as Prompt from "../effect/prompt"
 import open from "open"
 
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
 ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
 
 const loginEffect = Effect.fn("login")(function* (url: string) {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
 
   yield* Prompt.intro("Log in")
   const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
 })
 
 const logoutEffect = Effect.fn("logout")(function* (email?: string) {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
   const accounts = yield* service.list()
   if (accounts.length === 0) return yield* println("Not logged in")
 
@@ -98,7 +98,7 @@ interface OrgChoice {
 }
 
 const switchEffect = Effect.fn("switch")(function* () {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
 
   const groups = yield* service.orgsByAccount()
   if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
 })
 
 const orgsEffect = Effect.fn("orgs")(function* () {
-  const service = yield* AccountService
+  const service = yield* AccountEffect.Service
 
   const groups = yield* service.orgsByAccount()
   if (groups.length === 0) return yield* println("No accounts found")

+ 4 - 4
packages/opencode/src/effect/instances.ts

@@ -3,11 +3,11 @@ import { FileService } from "@/file"
 import { FileTimeService } from "@/file/time"
 import { FileWatcherService } from "@/file/watcher"
 import { FormatService } from "@/format"
-import { PermissionEffect } from "@/permission/service"
+import { PermissionEffect } from "@/permission/effect"
 import { Instance } from "@/project/instance"
 import { VcsService } from "@/project/vcs"
 import { ProviderAuthService } from "@/provider/auth-service"
-import { QuestionService } from "@/question/service"
+import { QuestionEffect } from "@/question/effect"
 import { SkillService } from "@/skill/skill"
 import { SnapshotService } from "@/snapshot"
 import { InstanceContext } from "./instance-context"
@@ -16,7 +16,7 @@ import { registerDisposer } from "./instance-registry"
 export { InstanceContext } from "./instance-context"
 
 export type InstanceServices =
-  | QuestionService
+  | QuestionEffect.Service
   | PermissionEffect.Service
   | ProviderAuthService
   | FileWatcherService
@@ -36,7 +36,7 @@ export type InstanceServices =
 function lookup(_key: string) {
   const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
   return Layer.mergeAll(
-    Layer.fresh(QuestionService.layer),
+    Layer.fresh(QuestionEffect.layer),
     Layer.fresh(PermissionEffect.layer),
     Layer.fresh(ProviderAuthService.layer),
     Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),

+ 4 - 4
packages/opencode/src/effect/runtime.ts

@@ -1,6 +1,6 @@
 import { Effect, Layer, ManagedRuntime } from "effect"
-import { AccountService } from "@/account/service"
-import { AuthService } from "@/auth/service"
+import { AccountEffect } from "@/account/effect"
+import { AuthEffect } from "@/auth/effect"
 import { Instances } from "@/effect/instances"
 import type { InstanceServices } from "@/effect/instances"
 import { TruncateEffect } from "@/tool/truncate-effect"
@@ -8,10 +8,10 @@ import { Instance } from "@/project/instance"
 
 export const runtime = ManagedRuntime.make(
   Layer.mergeAll(
-    AccountService.defaultLayer, //
+    AccountEffect.defaultLayer, //
     TruncateEffect.defaultLayer,
     Instances.layer,
-  ).pipe(Layer.provideMerge(AuthService.defaultLayer)),
+  ).pipe(Layer.provideMerge(AuthEffect.defaultLayer)),
 )
 
 export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

+ 5 - 5
packages/opencode/src/permission/service.ts → packages/opencode/src/permission/effect.ts

@@ -109,7 +109,7 @@ export namespace PermissionEffect {
     message: z.string().optional(),
   })
 
-  export interface Api {
+  export interface Interface {
     readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
     readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
     readonly list: () => Effect.Effect<Request[]>
@@ -129,7 +129,7 @@ export namespace PermissionEffect {
     return match ?? { action: "ask", permission, pattern: "*" }
   }
 
-  export class Service extends ServiceMap.Service<Service, Api>()("@opencode/PermissionNext") {}
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
 
   export const layer = Layer.effect(
     Service,
@@ -141,7 +141,7 @@ export namespace PermissionEffect {
       const pending = new Map<PermissionID, PendingEntry>()
       const approved: Ruleset = row?.data ?? []
 
-      const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
+      const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
         const { ruleset, ...request } = input
         let needsAsk = false
 
@@ -177,7 +177,7 @@ export namespace PermissionEffect {
         )
       })
 
-      const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
+      const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
         const existing = pending.get(input.requestID)
         if (!existing) return
 
@@ -234,7 +234,7 @@ export namespace PermissionEffect {
         }
       })
 
-      const list = Effect.fn("PermissionService.list")(function* () {
+      const list = Effect.fn("Permission.list")(function* () {
         return Array.from(pending.values(), (item) => item.info)
       })
 

+ 1 - 1
packages/opencode/src/permission/index.ts

@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
 import { fn } from "@/util/fn"
 import { Wildcard } from "@/util/wildcard"
 import os from "os"
-import { PermissionEffect as S } from "./service"
+import { PermissionEffect as S } from "./effect"
 
 export namespace PermissionNext {
   function expand(pattern: string): string {

+ 4 - 4
packages/opencode/src/provider/auth-service.ts

@@ -1,6 +1,6 @@
 import type { AuthOuathResult } from "@opencode-ai/plugin"
 import { NamedError } from "@opencode-ai/util/error"
-import * as Auth from "@/auth/service"
+import * as Auth from "@/auth/effect"
 import { ProviderID } from "./schema"
 import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
 import { filter, fromEntries, map, pipe } from "remeda"
@@ -44,7 +44,7 @@ export const OauthCodeMissing = NamedError.create(
 export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
 
 export type ProviderAuthError =
-  | Auth.AuthServiceError
+  | Auth.AuthEffect.AuthServiceError
   | InstanceType<typeof OauthMissing>
   | InstanceType<typeof OauthCodeMissing>
   | InstanceType<typeof OauthCallbackFailed>
@@ -67,7 +67,7 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
   static readonly layer = Layer.effect(
     ProviderAuthService,
     Effect.gen(function* () {
-      const auth = yield* Auth.AuthService
+      const auth = yield* Auth.AuthEffect.Service
       const hooks = yield* Effect.promise(async () => {
         const mod = await import("../plugin")
         return pipe(
@@ -139,5 +139,5 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
     }),
   )
 
-  static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
+  static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthEffect.defaultLayer))
 }

+ 168 - 0
packages/opencode/src/question/effect.ts

@@ -0,0 +1,168 @@
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { SessionID, MessageID } from "@/session/schema"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
+
+export namespace QuestionEffect {
+  const log = Log.create({ service: "question" })
+
+  export const Option = z
+    .object({
+      label: z.string().describe("Display text (1-5 words, concise)"),
+      description: z.string().describe("Explanation of choice"),
+    })
+    .meta({ ref: "QuestionOption" })
+  export type Option = z.infer<typeof Option>
+
+  export const Info = z
+    .object({
+      question: z.string().describe("Complete question"),
+      header: z.string().describe("Very short label (max 30 chars)"),
+      options: z.array(Option).describe("Available choices"),
+      multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
+      custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
+    })
+    .meta({ ref: "QuestionInfo" })
+  export type Info = z.infer<typeof Info>
+
+  export const Request = z
+    .object({
+      id: QuestionID.zod,
+      sessionID: SessionID.zod,
+      questions: z.array(Info).describe("Questions to ask"),
+      tool: z
+        .object({
+          messageID: MessageID.zod,
+          callID: z.string(),
+        })
+        .optional(),
+    })
+    .meta({ ref: "QuestionRequest" })
+  export type Request = z.infer<typeof Request>
+
+  export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+  export type Answer = z.infer<typeof Answer>
+
+  export const Reply = z.object({
+    answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
+  })
+  export type Reply = z.infer<typeof Reply>
+
+  export const Event = {
+    Asked: BusEvent.define("question.asked", Request),
+    Replied: BusEvent.define(
+      "question.replied",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: QuestionID.zod,
+        answers: z.array(Answer),
+      }),
+    ),
+    Rejected: BusEvent.define(
+      "question.rejected",
+      z.object({
+        sessionID: SessionID.zod,
+        requestID: QuestionID.zod,
+      }),
+    ),
+  }
+
+  export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
+    override get message() {
+      return "The user dismissed this question"
+    }
+  }
+
+  export type Error = RejectedError
+
+  interface Pending {
+    info: Request
+    deferred: Deferred.Deferred<Answer[], RejectedError>
+  }
+
+  export interface Interface {
+    readonly ask: (input: {
+      sessionID: SessionID
+      questions: Info[]
+      tool?: { messageID: MessageID; callID: string }
+    }) => Effect.Effect<Answer[], RejectedError>
+    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
+    readonly reject: (requestID: QuestionID) => Effect.Effect<void>
+    readonly list: () => Effect.Effect<Request[]>
+  }
+
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const pending = new Map<QuestionID, Pending>()
+
+      const ask = Effect.fn("Question.ask")(function* (input: {
+        sessionID: SessionID
+        questions: Info[]
+        tool?: { messageID: MessageID; callID: string }
+      }) {
+        const id = QuestionID.ascending()
+        log.info("asking", { id, questions: input.questions.length })
+
+        const deferred = yield* Deferred.make<Answer[], RejectedError>()
+        const info: Request = {
+          id,
+          sessionID: input.sessionID,
+          questions: input.questions,
+          tool: input.tool,
+        }
+        pending.set(id, { info, deferred })
+        Bus.publish(Event.Asked, info)
+
+        return yield* Effect.ensuring(
+          Deferred.await(deferred),
+          Effect.sync(() => {
+            pending.delete(id)
+          }),
+        )
+      })
+
+      const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
+        const existing = pending.get(input.requestID)
+        if (!existing) {
+          log.warn("reply for unknown request", { requestID: input.requestID })
+          return
+        }
+        pending.delete(input.requestID)
+        log.info("replied", { requestID: input.requestID, answers: input.answers })
+        Bus.publish(Event.Replied, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+          answers: input.answers,
+        })
+        yield* Deferred.succeed(existing.deferred, input.answers)
+      })
+
+      const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
+        const existing = pending.get(requestID)
+        if (!existing) {
+          log.warn("reject for unknown request", { requestID })
+          return
+        }
+        pending.delete(requestID)
+        log.info("rejected", { requestID })
+        Bus.publish(Event.Rejected, {
+          sessionID: existing.info.sessionID,
+          requestID: existing.info.id,
+        })
+        yield* Deferred.fail(existing.deferred, new RejectedError())
+      })
+
+      const list = Effect.fn("Question.list")(function* () {
+        return Array.from(pending.values(), (x) => x.info)
+      })
+
+      return Service.of({ ask, reply, reject, list })
+    }),
+  )
+}

+ 17 - 17
packages/opencode/src/question/index.ts

@@ -1,39 +1,39 @@
 import { runPromiseInstance } from "@/effect/runtime"
-import * as S from "./service"
+import * as S from "./effect"
 import type { QuestionID } from "./schema"
 import type { SessionID, MessageID } from "@/session/schema"
 
 export namespace Question {
-  export const Option = S.Option
-  export type Option = S.Option
-  export const Info = S.Info
-  export type Info = S.Info
-  export const Request = S.Request
-  export type Request = S.Request
-  export const Answer = S.Answer
-  export type Answer = S.Answer
-  export const Reply = S.Reply
-  export type Reply = S.Reply
-  export const Event = S.Event
-  export const RejectedError = S.RejectedError
+  export const Option = S.QuestionEffect.Option
+  export type Option = S.QuestionEffect.Option
+  export const Info = S.QuestionEffect.Info
+  export type Info = S.QuestionEffect.Info
+  export const Request = S.QuestionEffect.Request
+  export type Request = S.QuestionEffect.Request
+  export const Answer = S.QuestionEffect.Answer
+  export type Answer = S.QuestionEffect.Answer
+  export const Reply = S.QuestionEffect.Reply
+  export type Reply = S.QuestionEffect.Reply
+  export const Event = S.QuestionEffect.Event
+  export const RejectedError = S.QuestionEffect.RejectedError
 
   export async function ask(input: {
     sessionID: SessionID
     questions: Info[]
     tool?: { messageID: MessageID; callID: string }
   }): Promise<Answer[]> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
+    return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.ask(input)))
   }
 
   export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
+    return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reply(input)))
   }
 
   export async function reject(requestID: QuestionID): Promise<void> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
+    return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reject(requestID)))
   }
 
   export async function list(): Promise<Request[]> {
-    return runPromiseInstance(S.QuestionService.use((service) => service.list()))
+    return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.list()))
   }
 }

+ 0 - 172
packages/opencode/src/question/service.ts

@@ -1,172 +0,0 @@
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { SessionID, MessageID } from "@/session/schema"
-import { Log } from "@/util/log"
-import z from "zod"
-import { QuestionID } from "./schema"
-
-const log = Log.create({ service: "question" })
-
-// --- Zod schemas (re-exported by facade) ---
-
-export const Option = z
-  .object({
-    label: z.string().describe("Display text (1-5 words, concise)"),
-    description: z.string().describe("Explanation of choice"),
-  })
-  .meta({ ref: "QuestionOption" })
-export type Option = z.infer<typeof Option>
-
-export const Info = z
-  .object({
-    question: z.string().describe("Complete question"),
-    header: z.string().describe("Very short label (max 30 chars)"),
-    options: z.array(Option).describe("Available choices"),
-    multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
-    custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
-  })
-  .meta({ ref: "QuestionInfo" })
-export type Info = z.infer<typeof Info>
-
-export const Request = z
-  .object({
-    id: QuestionID.zod,
-    sessionID: SessionID.zod,
-    questions: z.array(Info).describe("Questions to ask"),
-    tool: z
-      .object({
-        messageID: MessageID.zod,
-        callID: z.string(),
-      })
-      .optional(),
-  })
-  .meta({ ref: "QuestionRequest" })
-export type Request = z.infer<typeof Request>
-
-export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
-export type Answer = z.infer<typeof Answer>
-
-export const Reply = z.object({
-  answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
-})
-export type Reply = z.infer<typeof Reply>
-
-export const Event = {
-  Asked: BusEvent.define("question.asked", Request),
-  Replied: BusEvent.define(
-    "question.replied",
-    z.object({
-      sessionID: SessionID.zod,
-      requestID: QuestionID.zod,
-      answers: z.array(Answer),
-    }),
-  ),
-  Rejected: BusEvent.define(
-    "question.rejected",
-    z.object({
-      sessionID: SessionID.zod,
-      requestID: QuestionID.zod,
-    }),
-  ),
-}
-
-export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
-  override get message() {
-    return "The user dismissed this question"
-  }
-}
-
-// --- Effect service ---
-
-interface PendingEntry {
-  info: Request
-  deferred: Deferred.Deferred<Answer[], RejectedError>
-}
-
-export namespace QuestionService {
-  export interface Service {
-    readonly ask: (input: {
-      sessionID: SessionID
-      questions: Info[]
-      tool?: { messageID: MessageID; callID: string }
-    }) => Effect.Effect<Answer[], RejectedError>
-    readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
-    readonly reject: (requestID: QuestionID) => Effect.Effect<void>
-    readonly list: () => Effect.Effect<Request[]>
-  }
-}
-
-export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
-  "@opencode/Question",
-) {
-  static readonly layer = Layer.effect(
-    QuestionService,
-    Effect.gen(function* () {
-      const pending = new Map<QuestionID, PendingEntry>()
-
-      const ask = Effect.fn("QuestionService.ask")(function* (input: {
-        sessionID: SessionID
-        questions: Info[]
-        tool?: { messageID: MessageID; callID: string }
-      }) {
-        const id = QuestionID.ascending()
-        log.info("asking", { id, questions: input.questions.length })
-
-        const deferred = yield* Deferred.make<Answer[], RejectedError>()
-        const info: Request = {
-          id,
-          sessionID: input.sessionID,
-          questions: input.questions,
-          tool: input.tool,
-        }
-        pending.set(id, { info, deferred })
-        Bus.publish(Event.Asked, info)
-
-        return yield* Effect.ensuring(
-          Deferred.await(deferred),
-          Effect.sync(() => {
-            pending.delete(id)
-          }),
-        )
-      })
-
-      const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
-        const existing = pending.get(input.requestID)
-        if (!existing) {
-          log.warn("reply for unknown request", { requestID: input.requestID })
-          return
-        }
-        pending.delete(input.requestID)
-        log.info("replied", { requestID: input.requestID, answers: input.answers })
-        Bus.publish(Event.Replied, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-          answers: input.answers,
-        })
-        yield* Deferred.succeed(existing.deferred, input.answers)
-      })
-
-      const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
-        const existing = pending.get(requestID)
-        if (!existing) {
-          log.warn("reject for unknown request", { requestID })
-          return
-        }
-        pending.delete(requestID)
-        log.info("rejected", { requestID })
-        Bus.publish(Event.Rejected, {
-          sessionID: existing.info.sessionID,
-          requestID: existing.info.id,
-        })
-        yield* Deferred.fail(existing.deferred, new RejectedError())
-      })
-
-      const list = Effect.fn("QuestionService.list")(function* () {
-        return Array.from(pending.values(), (x) => x.info)
-      })
-
-      return QuestionService.of({ ask, reply, reject, list })
-    }),
-  )
-}

+ 1 - 1
packages/opencode/src/tool/truncate-effect.ts

@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
 import path from "path"
 import type { Agent } from "../agent/agent"
-import { PermissionEffect } from "../permission/service"
+import { PermissionEffect } from "../permission/effect"
 import { Identifier } from "../id/id"
 import { Log } from "../util/log"
 import { ToolID } from "./schema"

+ 6 - 6
packages/opencode/test/account/service.test.ts

@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
 
 import { AccountRepo } from "../../src/account/repo"
-import { AccountService } from "../../src/account/service"
+import { AccountEffect } from "../../src/account/effect"
 import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
 import { Database } from "../../src/storage/db"
 import { testEffect } from "../lib/effect"
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
 const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
 
 const live = (client: HttpClient.HttpClient) =>
-  AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
+  AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
 
 const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
   HttpClientResponse.fromWeb(
@@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
       }),
     )
 
-    const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
+    const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
 
     expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
       [AccountID.make("user-1"), [OrgID.make("org-1")]],
@@ -118,7 +118,7 @@ it.effect("token refresh persists the new token", () =>
       ),
     )
 
-    const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
+    const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
 
     expect(Option.getOrThrow(token)).toBeDefined()
     expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -161,7 +161,7 @@ it.effect("config sends the selected org header", () =>
       }),
     )
 
-    const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
+    const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
 
     expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
     expect(seen).toEqual({
@@ -199,7 +199,7 @@ it.effect("poll stores the account and first org on success", () =>
       ),
     )
 
-    const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+    const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
 
     expect(res._tag).toBe("PollSuccess")
     if (res._tag === "PollSuccess") {

+ 1 - 1
packages/opencode/test/permission/next.test.ts

@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
 import { runtime } from "../../src/effect/runtime"
 import { Instances } from "../../src/effect/instances"
 import { PermissionNext } from "../../src/permission"
-import * as S from "../../src/permission/service"
+import * as S from "../../src/permission/effect"
 import { PermissionID } from "../../src/permission/schema"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"