summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/script/seed-e2e.ts2
-rw-r--r--packages/opencode/specs/effect-migration.md107
-rw-r--r--packages/opencode/src/account/effect.ts380
-rw-r--r--packages/opencode/src/account/index.ts397
-rw-r--r--packages/opencode/src/agent/agent.ts44
-rw-r--r--packages/opencode/src/auth/effect.ts94
-rw-r--r--packages/opencode/src/auth/index.ts130
-rw-r--r--packages/opencode/src/cli/cmd/account.ts11
-rw-r--r--packages/opencode/src/cli/cmd/debug/agent.ts12
-rw-r--r--packages/opencode/src/cli/cmd/run.ts4
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx2
-rw-r--r--packages/opencode/src/config/config.ts2
-rw-r--r--packages/opencode/src/effect/instance-state.ts47
-rw-r--r--packages/opencode/src/effect/instances.ts68
-rw-r--r--packages/opencode/src/effect/run-service.ts13
-rw-r--r--packages/opencode/src/effect/runtime.ts25
-rw-r--r--packages/opencode/src/file/index.ts706
-rw-r--r--packages/opencode/src/file/service.ts674
-rw-r--r--packages/opencode/src/file/time-service.ts93
-rw-r--r--packages/opencode/src/file/time.ts120
-rw-r--r--packages/opencode/src/file/watcher.ts166
-rw-r--r--packages/opencode/src/format/index.ts182
-rw-r--r--packages/opencode/src/format/service.ts152
-rw-r--r--packages/opencode/src/installation/index.ts19
-rw-r--r--packages/opencode/src/permission/index.ts336
-rw-r--r--packages/opencode/src/permission/service.ts282
-rw-r--r--packages/opencode/src/project/bootstrap.ts8
-rw-r--r--packages/opencode/src/project/instance.ts8
-rw-r--r--packages/opencode/src/project/vcs.ts96
-rw-r--r--packages/opencode/src/provider/auth-service.ts215
-rw-r--r--packages/opencode/src/provider/auth.ts266
-rw-r--r--packages/opencode/src/question/index.ts218
-rw-r--r--packages/opencode/src/question/service.ts172
-rw-r--r--packages/opencode/src/server/routes/permission.ts10
-rw-r--r--packages/opencode/src/server/routes/session.ts8
-rw-r--r--packages/opencode/src/server/server.ts7
-rw-r--r--packages/opencode/src/session/index.ts10
-rw-r--r--packages/opencode/src/session/llm.ts8
-rw-r--r--packages/opencode/src/session/message-v2.ts2
-rw-r--r--packages/opencode/src/session/processor.ts8
-rw-r--r--packages/opencode/src/session/prompt.ts14
-rw-r--r--packages/opencode/src/session/session.sql.ts8
-rw-r--r--packages/opencode/src/session/system.ts4
-rw-r--r--packages/opencode/src/share/share-next.ts2
-rw-r--r--packages/opencode/src/skill/index.ts261
-rw-r--r--packages/opencode/src/skill/service.ts238
-rw-r--r--packages/opencode/src/skill/skill.ts35
-rw-r--r--packages/opencode/src/snapshot/index.ts386
-rw-r--r--packages/opencode/src/snapshot/service.ts320
-rw-r--r--packages/opencode/src/tool/apply_patch.ts2
-rw-r--r--packages/opencode/src/tool/edit.ts4
-rw-r--r--packages/opencode/src/tool/question.ts5
-rw-r--r--packages/opencode/src/tool/task.ts4
-rw-r--r--packages/opencode/src/tool/tool.ts4
-rw-r--r--packages/opencode/src/tool/truncate-effect.ts137
-rw-r--r--packages/opencode/src/tool/truncate.ts144
-rw-r--r--packages/opencode/src/tool/write.ts2
-rw-r--r--packages/opencode/test/account/service.test.ts2
-rw-r--r--packages/opencode/test/agent/agent.test.ts38
-rw-r--r--packages/opencode/test/config/config.test.ts2
-rw-r--r--packages/opencode/test/effect/instance-state.test.ts384
-rw-r--r--packages/opencode/test/effect/run-service.test.ts46
-rw-r--r--packages/opencode/test/effect/runtime.test.ts128
-rw-r--r--packages/opencode/test/file/index.test.ts96
-rw-r--r--packages/opencode/test/file/time.test.ts26
-rw-r--r--packages/opencode/test/file/watcher.test.ts6
-rw-r--r--packages/opencode/test/format/format.test.ts109
-rw-r--r--packages/opencode/test/permission-task.test.ts114
-rw-r--r--packages/opencode/test/permission/next.test.ts370
-rw-r--r--packages/opencode/test/plugin/auth-override.test.ts23
-rw-r--r--packages/opencode/test/project/vcs.test.ts8
-rw-r--r--packages/opencode/test/question/question.test.ts131
-rw-r--r--packages/opencode/test/share/share-next.test.ts6
-rw-r--r--packages/opencode/test/skill/skill.test.ts6
-rw-r--r--packages/opencode/test/snapshot/snapshot.test.ts6
-rw-r--r--packages/opencode/test/tool/bash.test.ts42
-rw-r--r--packages/opencode/test/tool/edit.test.ts6
-rw-r--r--packages/opencode/test/tool/external-directory.test.ts12
-rw-r--r--packages/opencode/test/tool/read.test.ts30
-rw-r--r--packages/opencode/test/tool/registry.test.ts6
-rw-r--r--packages/opencode/test/tool/skill.test.ts10
-rw-r--r--packages/opencode/test/tool/task.test.ts6
-rw-r--r--packages/opencode/test/tool/truncation.test.ts5
-rw-r--r--packages/opencode/test/tool/write.test.ts6
84 files changed, 4546 insertions, 3752 deletions
diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts
index fc3573548..f5bd7194f 100644
--- a/packages/opencode/script/seed-e2e.ts
+++ b/packages/opencode/script/seed-e2e.ts
@@ -11,7 +11,6 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
- const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
@@ -55,7 +54,6 @@ const seed = async () => {
})
} finally {
await Instance.disposeAll().catch(() => {})
- await disposeRuntime().catch(() => {})
}
}
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
index 4f195917f..80c906fcc 100644
--- a/packages/opencode/specs/effect-migration.md
+++ b/packages/opencode/specs/effect-migration.md
@@ -4,18 +4,18 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
## Choose scope
-Use the shared runtime for process-wide services with one lifecycle for the whole app.
+Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
-Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
+Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
-- Shared runtime: config readers, stateless helpers, global clients
-- Instance-scoped: watchers, per-project caches, session state, project-bound background work
+- Global services (no per-directory state): Account, Auth, Installation, Truncate
+- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
-Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
+Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
## Service shape
-For a fully migrated module, use the public namespace directly:
+Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
```ts
export namespace Foo {
@@ -28,53 +28,52 @@ export namespace Foo {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- return Service.of({
- get: Effect.fn("Foo.get")(function* (id) {
- return yield* ...
- }),
+ // For instance-scoped services:
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
+ )
+
+ const get = Effect.fn("Foo.get")(function* (id: FooID) {
+ const s = yield* InstanceState.get(state)
+ // ...
})
+
+ return Service.of({ get })
}),
)
- export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
+ // Optional: wire dependencies
+ export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
+
+ // Per-service runtime (inside the namespace)
+ const runPromise = makeRunPromise(Service, defaultLayer)
+
+ // Async facade functions
+ export async function get(id: FooID) {
+ return runPromise((svc) => svc.get(id))
+ }
}
```
Rules:
-- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
-- Export `defaultLayer` only when wiring dependencies is useful
-- Use the direct namespace form once the module is fully migrated
-
-## Temporary mixed-mode pattern
+- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
+- `runPromise` goes inside the namespace (not exported unless tests need it)
+- Facade functions are plain `async function` — no `fn()` wrappers
+- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
+- No `Layer.fresh` — InstanceState handles per-directory isolation
-Prefer a single namespace whenever possible.
+## Schema → Zod interop
-Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
+When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
```ts
-export namespace FooEffect {
- export interface Interface {
- readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
+import { zod } from "@/util/effect-zod"
- export const layer = Layer.effect(...)
-}
-```
-
-Then keep the old boundary thin:
-
-```ts
-export namespace Foo {
- export function get(id: FooID) {
- return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
- }
-}
+export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
```
-Remove the `Effect` suffix when the boundary split is gone.
+See `Auth.ZodInfo` for the canonical example.
## Scheduled Tasks
@@ -107,22 +106,23 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
## Migration checklist
-Done now:
-
-- [x] `AccountEffect` (mixed-mode)
-- [x] `AuthEffect` (mixed-mode)
-- [x] `TruncateEffect` (mixed-mode)
-- [x] `Question`
-- [x] `PermissionNext`
-- [x] `ProviderAuth`
-- [x] `FileWatcher`
-- [x] `FileTime`
-- [x] `Format`
-- [x] `Vcs`
-- [x] `Skill`
-- [x] `Discovery`
-- [x] `File`
-- [x] `Snapshot`
+Fully migrated (single namespace, InstanceState where needed, flattened facade):
+
+- [x] `Account` — `account/index.ts`
+- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
+- [x] `File` — `file/index.ts`
+- [x] `FileTime` — `file/time.ts`
+- [x] `FileWatcher` — `file/watcher.ts`
+- [x] `Format` — `format/index.ts`
+- [x] `Installation` — `installation/index.ts`
+- [x] `Permission` — `permission/index.ts`
+- [x] `ProviderAuth` — `provider/auth.ts`
+- [x] `Question` — `question/index.ts`
+- [x] `Skill` — `skill/index.ts`
+- [x] `Snapshot` — `snapshot/index.ts`
+- [x] `Truncate` — `tool/truncate.ts`
+- [x] `Vcs` — `project/vcs.ts`
+- [x] `Discovery` — `skill/discovery.ts`
Still open and likely worth migrating:
@@ -130,7 +130,6 @@ Still open and likely worth migrating:
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
-- [ ] `Installation`
- [ ] `Bus`
- [ ] `Command`
- [ ] `Config`
diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts
deleted file mode 100644
index 8686ef42a..000000000
--- a/packages/opencode/src/account/effect.ts
+++ /dev/null
@@ -1,380 +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,
- AccountID,
- DeviceCode,
- Info,
- RefreshToken,
- AccountServiceError,
- Login,
- Org,
- OrgID,
- PollDenied,
- PollError,
- PollExpired,
- PollPending,
- type PollResult,
- PollSlow,
- PollSuccess,
- UserCode,
-} from "./schema"
-
-export {
- AccountID,
- type AccountError,
- AccountRepoError,
- AccountServiceError,
- AccessToken,
- RefreshToken,
- DeviceCode,
- UserCode,
- Info,
- Org,
- OrgID,
- Login,
- PollSuccess,
- PollPending,
- PollSlow,
- PollExpired,
- PollDenied,
- PollError,
- PollResult,
-} from "./schema"
-
-export type AccountOrgs = {
- account: Info
- 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 Account {
- export interface Interface {
- readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
- readonly list: () => Effect.Effect<Info[], 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 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(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"),
- )
-
- const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
- request.pipe(
- Effect.flatMap((req) => http.execute(req)),
- mapAccountServiceError("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: 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("Account.token")((accountID: AccountID) =>
- resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.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 error of errors) {
- yield* Effect.logWarning("failed to fetch orgs for account").pipe(
- Effect.annotateLogs({ error: String(error) }),
- )
- }
- return results
- })
-
- const orgs = Effect.fn("Account.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("Account.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("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: 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("Account.poll")(function* (input: Login) {
- const response = yield* executeEffect(
- 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 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))
-}
diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts
index 753b80c5f..0a8d3687a 100644
--- a/packages/opencode/src/account/index.ts
+++ b/packages/opencode/src/account/index.ts
@@ -1,34 +1,397 @@
-import { Effect, Option } from "effect"
+import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
+import { makeRunPromise } from "@/effect/run-service"
+import { withTransientReadRetry } from "@/util/effect-http-client"
+import { AccountRepo, type AccountRow } from "./repo"
+import {
+ type AccountError,
+ AccessToken,
+ AccountID,
+ DeviceCode,
+ Info,
+ RefreshToken,
+ AccountServiceError,
+ Login,
+ Org,
+ OrgID,
+ PollDenied,
+ PollError,
+ PollExpired,
+ PollPending,
+ type PollResult,
+ PollSlow,
+ PollSuccess,
+ UserCode,
+} from "./schema"
-export { AccessToken, AccountID, OrgID } from "./effect"
+export {
+ AccountID,
+ type AccountError,
+ AccountRepoError,
+ AccountServiceError,
+ AccessToken,
+ RefreshToken,
+ DeviceCode,
+ UserCode,
+ Info,
+ Org,
+ OrgID,
+ Login,
+ PollSuccess,
+ PollPending,
+ PollSlow,
+ PollExpired,
+ PollDenied,
+ PollError,
+ PollResult,
+} from "./schema"
-import { runtime } from "@/effect/runtime"
-
-function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
- return runtime.runSync(S.Service.use(f))
+export type AccountOrgs = {
+ account: Info
+ orgs: readonly Org[]
}
-function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
- return runtime.runPromise(S.Service.use(f))
+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 Account {
- export const Info = Model
- export type Info = Model
+ export interface Interface {
+ readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
+ readonly list: () => Effect.Effect<Info[], 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 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(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"),
+ )
+
+ const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+ request.pipe(
+ Effect.flatMap((req) => http.execute(req)),
+ mapAccountServiceError("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: 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("Account.token")((accountID: AccountID) =>
+ resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.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 error of errors) {
+ yield* Effect.logWarning("failed to fetch orgs for account").pipe(
+ Effect.annotateLogs({ error: String(error) }),
+ )
+ }
+ return results
+ })
+
+ const orgs = Effect.fn("Account.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("Account.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("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: 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("Account.poll")(function* (input: Login) {
+ const response = yield* executeEffect(
+ 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 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))
+
+ export const runPromise = makeRunPromise(Service, defaultLayer)
- export function active(): Info | undefined {
- return Option.getOrUndefined(runSync((service) => service.active()))
+ export async function active(): Promise<Info | undefined> {
+ return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
- const config = await runPromise((service) => service.config(accountID, orgID))
- return Option.getOrUndefined(config)
+ const cfg = await runPromise((service) => service.config(accountID, orgID))
+ return Option.getOrUndefined(cfg)
}
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
- const token = await runPromise((service) => service.token(accountID))
- return Option.getOrUndefined(token)
+ const t = await runPromise((service) => service.token(accountID))
+ return Option.getOrUndefined(t)
}
}
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 5a629c73e..30d098614 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
@@ -32,7 +32,7 @@ export namespace Agent {
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
- permission: PermissionNext.Ruleset,
+ permission: Permission.Ruleset,
model: z
.object({
modelID: ModelID.zod,
@@ -54,7 +54,7 @@ export namespace Agent {
const skillDirs = await Skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
- const defaults = PermissionNext.fromConfig({
+ const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
@@ -72,16 +72,16 @@ export namespace Agent {
"*.env.example": "allow",
},
})
- const user = PermissionNext.fromConfig(cfg.permission ?? {})
+ const user = Permission.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
question: "allow",
plan_enter: "allow",
}),
@@ -94,9 +94,9 @@ export namespace Agent {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
@@ -116,9 +116,9 @@ export namespace Agent {
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
@@ -130,9 +130,9 @@ export namespace Agent {
},
explore: {
name: "explore",
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
@@ -161,9 +161,9 @@ export namespace Agent {
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
"*": "deny",
}),
user,
@@ -177,9 +177,9 @@ export namespace Agent {
native: true,
hidden: true,
temperature: 0.5,
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
"*": "deny",
}),
user,
@@ -192,9 +192,9 @@ export namespace Agent {
options: {},
native: true,
hidden: true,
- permission: PermissionNext.merge(
+ permission: Permission.merge(
defaults,
- PermissionNext.fromConfig({
+ Permission.fromConfig({
"*": "deny",
}),
user,
@@ -213,7 +213,7 @@ export namespace Agent {
item = result[key] = {
name: key,
mode: "all",
- permission: PermissionNext.merge(defaults, user),
+ permission: Permission.merge(defaults, user),
options: {},
native: false,
}
@@ -229,7 +229,7 @@ export namespace Agent {
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
- item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
+ item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
@@ -242,9 +242,9 @@ export namespace Agent {
})
if (explicit) continue
- result[name].permission = PermissionNext.merge(
+ result[name].permission = Permission.merge(
result[name].permission,
- PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
+ Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts
deleted file mode 100644
index 14a970807..000000000
--- a/packages/opencode/src/auth/effect.ts
+++ /dev/null
@@ -1,94 +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 AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
- message: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {}
-
-const file = path.join(Global.Path.data, "auth.json")
-
-const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
-
-export namespace Auth {
- export interface Interface {
- readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
- readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
- readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
- readonly remove: (key: string) => Effect.Effect<void, AuthError>
- }
-
- 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 })
- }),
- )
-}
diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts
index 411d9dccc..c50040f1d 100644
--- a/packages/opencode/src/auth/index.ts
+++ b/packages/opencode/src/auth/index.ts
@@ -1,43 +1,101 @@
-import { Effect } from "effect"
-import z from "zod"
-import { runtime } from "@/effect/runtime"
-import * as S from "./effect"
+import path from "path"
+import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
+import { makeRunPromise } from "@/effect/run-service"
+import { zod } from "@/util/effect-zod"
+import { Global } from "../global"
+import { Filesystem } from "../util/filesystem"
-export { OAUTH_DUMMY_KEY } from "./effect"
+export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
-function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
- return runtime.runPromise(S.Auth.Service.use(f))
-}
+const file = path.join(Global.Path.data, "auth.json")
+
+const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
export namespace Auth {
- export const Oauth = z
- .object({
- type: z.literal("oauth"),
- refresh: z.string(),
- access: z.string(),
- expires: z.number(),
- accountId: z.string().optional(),
- enterpriseUrl: z.string().optional(),
- })
- .meta({ ref: "OAuth" })
-
- export const Api = z
- .object({
- type: z.literal("api"),
- key: z.string(),
- })
- .meta({ ref: "ApiAuth" })
-
- export const WellKnown = z
- .object({
- type: z.literal("wellknown"),
- key: z.string(),
- token: z.string(),
- })
- .meta({ ref: "WellKnownAuth" })
-
- export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
- export type Info = z.infer<typeof Info>
+ 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,
+ }) {}
+
+ const _Info = Schema.Union([Oauth, Api, WellKnown])
+ export const Info = Object.assign(_Info, { zod: zod(_Info) })
+ export type Info = Schema.Schema.Type<typeof _Info>
+
+ export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
+ message: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+ }) {}
+
+ export interface Interface {
+ readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
+ readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
+ readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
+ readonly remove: (key: string) => Effect.Effect<void, AuthError>
+ }
+
+ 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 })
+ }),
+ )
+
+ const runPromise = makeRunPromise(Service, layer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))
diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts
index fb702c95a..fe8747bce 100644
--- a/packages/opencode/src/cli/cmd/account.ts
+++ b/packages/opencode/src/cli/cmd/account.ts
@@ -1,8 +1,7 @@
import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
-import { runtime } from "@/effect/runtime"
-import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
+import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -160,7 +159,7 @@ export const LoginCommand = cmd({
}),
async handler(args) {
UI.empty()
- await runtime.runPromise(loginEffect(args.url))
+ await Account.runPromise((_svc) => loginEffect(args.url))
},
})
@@ -174,7 +173,7 @@ export const LogoutCommand = cmd({
}),
async handler(args) {
UI.empty()
- await runtime.runPromise(logoutEffect(args.email))
+ await Account.runPromise((_svc) => logoutEffect(args.email))
},
})
@@ -183,7 +182,7 @@ export const SwitchCommand = cmd({
describe: false,
async handler() {
UI.empty()
- await runtime.runPromise(switchEffect())
+ await Account.runPromise((_svc) => switchEffect())
},
})
@@ -192,7 +191,7 @@ export const OrgsCommand = cmd({
describe: false,
async handler() {
UI.empty()
- await runtime.runPromise(orgsEffect())
+ await Account.runPromise((_svc) => orgsEffect())
},
})
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
index f33dcc558..7f451e98c 100644
--- a/packages/opencode/src/cli/cmd/debug/agent.ts
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
-import { PermissionNext } from "../../../permission"
+import { Permission } from "../../../permission"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -75,7 +75,7 @@ async function getAvailableTools(agent: Agent.Info) {
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
- const disabled = PermissionNext.disabled(
+ const disabled = Permission.disabled(
availableTools.map((tool) => tool.id),
agent.permission,
)
@@ -145,7 +145,7 @@ async function createToolContext(agent: Agent.Info) {
}
await Session.updateMessage(message)
- const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
+ const ruleset = Permission.merge(agent.permission, session.permission ?? [])
return {
sessionID: session.id,
@@ -155,11 +155,11 @@ async function createToolContext(agent: Agent.Info) {
abort: new AbortController().signal,
messages: [],
metadata: () => {},
- async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
+ async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
- const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
+ const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
- throw new PermissionNext.DeniedError({ ruleset })
+ throw new Permission.DeniedError({ ruleset })
}
}
},
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 85b5689da..0aeb864e8 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server"
import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent"
-import { PermissionNext } from "../../permission"
+import { Permission } from "../../permission"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
@@ -354,7 +354,7 @@ export const RunCommand = cmd({
process.exit(1)
}
- const rules: PermissionNext.Ruleset = [
+ const rules: Permission.Ruleset = [
{
permission: "question",
action: "deny",
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index d06987734..3b296a927 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -22,7 +22,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper"
-import type { Snapshot } from "@/snapshot/service"
+import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 47afdfd7d..c464fcb64 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -177,7 +177,7 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
- const active = Account.active()
+ const active = await Account.active()
if (active?.active_org_id) {
try {
const [config, token] = await Promise.all([
diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts
new file mode 100644
index 000000000..fe3339ee6
--- /dev/null
+++ b/packages/opencode/src/effect/instance-state.ts
@@ -0,0 +1,47 @@
+import { Effect, ScopedCache, Scope } from "effect"
+import { Instance, type Shape } from "@/project/instance"
+import { registerDisposer } from "./instance-registry"
+
+const TypeId = "~opencode/InstanceState"
+
+export interface InstanceState<A, E = never, R = never> {
+ readonly [TypeId]: typeof TypeId
+ readonly cache: ScopedCache.ScopedCache<string, A, E, R>
+}
+
+export namespace InstanceState {
+ export const make = <A, E = never, R = never>(
+ init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
+ ): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
+ Effect.gen(function* () {
+ const cache = yield* ScopedCache.make<string, A, E, R>({
+ capacity: Number.POSITIVE_INFINITY,
+ lookup: () => init(Instance.current),
+ })
+
+ const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
+ yield* Effect.addFinalizer(() => Effect.sync(off))
+
+ return {
+ [TypeId]: TypeId,
+ cache,
+ }
+ })
+
+ export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
+ Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
+
+ export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
+ Effect.map(get(self), select)
+
+ export const useEffect = <A, E, R, B, E2, R2>(
+ self: InstanceState<A, E, R>,
+ select: (value: A) => Effect.Effect<B, E2, R2>,
+ ) => Effect.flatMap(get(self), select)
+
+ export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
+ Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
+
+ export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
+ Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
+}
diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts
deleted file mode 100644
index 6fcfddb24..000000000
--- a/packages/opencode/src/effect/instances.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Effect, Layer, LayerMap, ServiceMap } from "effect"
-import { File } from "@/file/service"
-import { FileTime } from "@/file/time-service"
-import { FileWatcher } from "@/file/watcher"
-import { Format } from "@/format/service"
-import { Permission } from "@/permission/service"
-import { Instance } from "@/project/instance"
-import { Vcs } from "@/project/vcs"
-import { ProviderAuth } from "@/provider/auth-service"
-import { Question } from "@/question/service"
-import { Skill } from "@/skill/service"
-import { Snapshot } from "@/snapshot/service"
-import { InstanceContext } from "./instance-context"
-import { registerDisposer } from "./instance-registry"
-
-export { InstanceContext } from "./instance-context"
-
-export type InstanceServices =
- | Question.Service
- | Permission.Service
- | ProviderAuth.Service
- | FileWatcher.Service
- | Vcs.Service
- | FileTime.Service
- | Format.Service
- | File.Service
- | Skill.Service
- | Snapshot.Service
-
-// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
-// the full instance context (directory, worktree, project). We read from the
-// legacy Instance ALS here, which is safe because lookup is only triggered via
-// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
-// This should go away once the old Instance type is removed and lookup can load
-// the full context directly.
-function lookup(_key: string) {
- const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
- return Layer.mergeAll(
- Question.layer,
- Permission.layer,
- ProviderAuth.defaultLayer,
- FileWatcher.layer,
- Vcs.layer,
- FileTime.layer,
- Format.layer,
- File.layer,
- Skill.defaultLayer,
- Snapshot.defaultLayer,
- ).pipe(Layer.provide(ctx))
-}
-
-export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<string, InstanceServices>>()(
- "opencode/Instances",
-) {
- static readonly layer = Layer.effect(
- Instances,
- Effect.gen(function* () {
- const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity })
- const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
- yield* Effect.addFinalizer(() => Effect.sync(unregister))
- return Instances.of(layerMap)
- }),
- )
-
- static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
- return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
- }
-}
diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts
new file mode 100644
index 000000000..226c276ea
--- /dev/null
+++ b/packages/opencode/src/effect/run-service.ts
@@ -0,0 +1,13 @@
+import { Effect, Layer, ManagedRuntime } from "effect"
+import * as ServiceMap from "effect/ServiceMap"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
+
+export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
+ let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+
+ return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
+ rt ??= ManagedRuntime.make(layer, { memoMap })
+ return rt.runPromise(service.use(fn), options)
+ }
+}
diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
deleted file mode 100644
index e6f1f3262..000000000
--- a/packages/opencode/src/effect/runtime.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Effect, Layer, ManagedRuntime } from "effect"
-import { Account } from "@/account/effect"
-import { Auth } from "@/auth/effect"
-import { Instances } from "@/effect/instances"
-import type { InstanceServices } from "@/effect/instances"
-import { Installation } from "@/installation"
-import { Truncate } from "@/tool/truncate-effect"
-import { Instance } from "@/project/instance"
-
-export const runtime = ManagedRuntime.make(
- Layer.mergeAll(
- Account.defaultLayer, //
- Installation.defaultLayer,
- Truncate.defaultLayer,
- Instances.layer,
- ).pipe(Layer.provideMerge(Auth.layer)),
-)
-
-export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
- return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
-}
-
-export function disposeRuntime() {
- return runtime.dispose()
-}
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 35a5b5e20..23c77e7bf 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -1,40 +1,712 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { File as S } from "./service"
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { git } from "@/util/git"
+import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
+import { formatPatch, structuredPatch } from "diff"
+import fs from "fs"
+import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import path from "path"
+import z from "zod"
+import { Global } from "../global"
+import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
+import { Protected } from "./protected"
+import { Ripgrep } from "./ripgrep"
export namespace File {
- export const Info = S.Info
- export type Info = S.Info
+ export const Info = z
+ .object({
+ path: z.string(),
+ added: z.number().int(),
+ removed: z.number().int(),
+ status: z.enum(["added", "deleted", "modified"]),
+ })
+ .meta({
+ ref: "File",
+ })
- export const Node = S.Node
- export type Node = S.Node
+ export type Info = z.infer<typeof Info>
- export const Content = S.Content
- export type Content = S.Content
+ export const Node = z
+ .object({
+ name: z.string(),
+ path: z.string(),
+ absolute: z.string(),
+ type: z.enum(["file", "directory"]),
+ ignored: z.boolean(),
+ })
+ .meta({
+ ref: "FileNode",
+ })
+ export type Node = z.infer<typeof Node>
- export const Event = S.Event
+ export const Content = z
+ .object({
+ type: z.enum(["text", "binary"]),
+ content: z.string(),
+ diff: z.string().optional(),
+ patch: z
+ .object({
+ oldFileName: z.string(),
+ newFileName: z.string(),
+ oldHeader: z.string().optional(),
+ newHeader: z.string().optional(),
+ hunks: z.array(
+ z.object({
+ oldStart: z.number(),
+ oldLines: z.number(),
+ newStart: z.number(),
+ newLines: z.number(),
+ lines: z.array(z.string()),
+ }),
+ ),
+ index: z.string().optional(),
+ })
+ .optional(),
+ encoding: z.literal("base64").optional(),
+ mimeType: z.string().optional(),
+ })
+ .meta({
+ ref: "FileContent",
+ })
+ export type Content = z.infer<typeof Content>
- export type Interface = S.Interface
+ export const Event = {
+ Edited: BusEvent.define(
+ "file.edited",
+ z.object({
+ file: z.string(),
+ }),
+ ),
+ }
+
+ const log = Log.create({ service: "file" })
+
+ const binary = new Set([
+ "exe",
+ "dll",
+ "pdb",
+ "bin",
+ "so",
+ "dylib",
+ "o",
+ "a",
+ "lib",
+ "wav",
+ "mp3",
+ "ogg",
+ "oga",
+ "ogv",
+ "ogx",
+ "flac",
+ "aac",
+ "wma",
+ "m4a",
+ "weba",
+ "mp4",
+ "avi",
+ "mov",
+ "wmv",
+ "flv",
+ "webm",
+ "mkv",
+ "zip",
+ "tar",
+ "gz",
+ "gzip",
+ "bz",
+ "bz2",
+ "bzip",
+ "bzip2",
+ "7z",
+ "rar",
+ "xz",
+ "lz",
+ "z",
+ "pdf",
+ "doc",
+ "docx",
+ "ppt",
+ "pptx",
+ "xls",
+ "xlsx",
+ "dmg",
+ "iso",
+ "img",
+ "vmdk",
+ "ttf",
+ "otf",
+ "woff",
+ "woff2",
+ "eot",
+ "sqlite",
+ "db",
+ "mdb",
+ "apk",
+ "ipa",
+ "aab",
+ "xapk",
+ "app",
+ "pkg",
+ "deb",
+ "rpm",
+ "snap",
+ "flatpak",
+ "appimage",
+ "msi",
+ "msp",
+ "jar",
+ "war",
+ "ear",
+ "class",
+ "kotlin_module",
+ "dex",
+ "vdex",
+ "odex",
+ "oat",
+ "art",
+ "wasm",
+ "wat",
+ "bc",
+ "ll",
+ "s",
+ "ko",
+ "sys",
+ "drv",
+ "efi",
+ "rom",
+ "com",
+ ])
+
+ const image = new Set([
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "bmp",
+ "webp",
+ "ico",
+ "tif",
+ "tiff",
+ "svg",
+ "svgz",
+ "avif",
+ "apng",
+ "jxl",
+ "heic",
+ "heif",
+ "raw",
+ "cr2",
+ "nef",
+ "arw",
+ "dng",
+ "orf",
+ "raf",
+ "pef",
+ "x3f",
+ ])
+
+ const text = new Set([
+ "ts",
+ "tsx",
+ "mts",
+ "cts",
+ "mtsx",
+ "ctsx",
+ "js",
+ "jsx",
+ "mjs",
+ "cjs",
+ "sh",
+ "bash",
+ "zsh",
+ "fish",
+ "ps1",
+ "psm1",
+ "cmd",
+ "bat",
+ "json",
+ "jsonc",
+ "json5",
+ "yaml",
+ "yml",
+ "toml",
+ "md",
+ "mdx",
+ "txt",
+ "xml",
+ "html",
+ "htm",
+ "css",
+ "scss",
+ "sass",
+ "less",
+ "graphql",
+ "gql",
+ "sql",
+ "ini",
+ "cfg",
+ "conf",
+ "env",
+ ])
+
+ const textName = new Set([
+ "dockerfile",
+ "makefile",
+ ".gitignore",
+ ".gitattributes",
+ ".editorconfig",
+ ".npmrc",
+ ".nvmrc",
+ ".prettierrc",
+ ".eslintrc",
+ ])
+
+ const mime: Record<string, string> = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ bmp: "image/bmp",
+ webp: "image/webp",
+ ico: "image/x-icon",
+ tif: "image/tiff",
+ tiff: "image/tiff",
+ svg: "image/svg+xml",
+ svgz: "image/svg+xml",
+ avif: "image/avif",
+ apng: "image/apng",
+ jxl: "image/jxl",
+ heic: "image/heic",
+ heif: "image/heif",
+ }
+
+ type Entry = { files: string[]; dirs: string[] }
+
+ const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
+ const name = (file: string) => path.basename(file).toLowerCase()
+ const isImageByExtension = (file: string) => image.has(ext(file))
+ const isTextByExtension = (file: string) => text.has(ext(file))
+ const isTextByName = (file: string) => textName.has(name(file))
+ const isBinaryByExtension = (file: string) => binary.has(ext(file))
+ const isImage = (mimeType: string) => mimeType.startsWith("image/")
+ const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
+
+ function shouldEncode(mimeType: string) {
+ const type = mimeType.toLowerCase()
+ log.debug("shouldEncode", { type })
+ if (!type) return false
+ if (type.startsWith("text/")) return false
+ if (type.includes("charset=")) return false
+ const top = type.split("/", 2)[0]
+ return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
+ }
+
+ const hidden = (item: string) => {
+ const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+ return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+ }
+
+ const sortHiddenLast = (items: string[], prefer: boolean) => {
+ if (prefer) return items
+ const visible: string[] = []
+ const hiddenItems: string[] = []
+ for (const item of items) {
+ if (hidden(item)) hiddenItems.push(item)
+ else visible.push(item)
+ }
+ return [...visible, ...hiddenItems]
+ }
+
+ interface State {
+ cache: Entry
+ fiber: Fiber.Fiber<void> | undefined
+ }
+
+ export interface Interface {
+ readonly init: () => Effect.Effect<void>
+ readonly status: () => Effect.Effect<File.Info[]>
+ readonly read: (file: string) => Effect.Effect<File.Content>
+ readonly list: (dir?: string) => Effect.Effect<File.Node[]>
+ readonly search: (input: {
+ query: string
+ limit?: number
+ dirs?: boolean
+ type?: "file" | "directory"
+ }) => Effect.Effect<string[]>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("File.state")(() =>
+ Effect.succeed({
+ cache: { files: [], dirs: [] } as Entry,
+ fiber: undefined as Fiber.Fiber<void> | undefined,
+ }),
+ ),
+ )
+
+ const scan = Effect.fn("File.scan")(function* () {
+ if (Instance.directory === path.parse(Instance.directory).root) return
+ const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
+ const next: Entry = { files: [], dirs: [] }
+
+ yield* Effect.promise(async () => {
+ if (isGlobalHome) {
+ const dirs = new Set<string>()
+ const protectedNames = Protected.names()
+ const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+ const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
+ const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
+ const top = await fs.promises
+ .readdir(Instance.directory, { withFileTypes: true })
+ .catch(() => [] as fs.Dirent[])
+
+ for (const entry of top) {
+ if (!entry.isDirectory()) continue
+ if (shouldIgnoreName(entry.name)) continue
+ dirs.add(entry.name + "/")
+
+ const base = path.join(Instance.directory, entry.name)
+ const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
+ for (const child of children) {
+ if (!child.isDirectory()) continue
+ if (shouldIgnoreNested(child.name)) continue
+ dirs.add(entry.name + "/" + child.name + "/")
+ }
+ }
+
+ next.dirs = Array.from(dirs).toSorted()
+ } else {
+ const seen = new Set<string>()
+ for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
+ next.files.push(file)
+ let current = file
+ while (true) {
+ const dir = path.dirname(current)
+ if (dir === ".") break
+ if (dir === current) break
+ current = dir
+ if (seen.has(dir)) continue
+ seen.add(dir)
+ next.dirs.push(dir + "/")
+ }
+ }
+ }
+ })
+
+ const s = yield* InstanceState.get(state)
+ s.cache = next
+ })
+
+ const scope = yield* Scope.Scope
+
+ const ensure = Effect.fn("File.ensure")(function* () {
+ const s = yield* InstanceState.get(state)
+ if (!s.fiber)
+ s.fiber = yield* scan().pipe(
+ Effect.catchCause(() => Effect.void),
+ Effect.ensuring(
+ Effect.sync(() => {
+ s.fiber = undefined
+ }),
+ ),
+ Effect.forkIn(scope),
+ )
+ yield* Fiber.join(s.fiber)
+ })
+
+ const init = Effect.fn("File.init")(function* () {
+ yield* ensure()
+ })
+
+ const status = Effect.fn("File.status")(function* () {
+ if (Instance.project.vcs !== "git") return []
+
+ return yield* Effect.promise(async () => {
+ const diffOutput = (
+ await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
+ cwd: Instance.directory,
+ })
+ ).text()
+
+ const changed: File.Info[] = []
+
+ if (diffOutput.trim()) {
+ for (const line of diffOutput.trim().split("\n")) {
+ const [added, removed, file] = line.split("\t")
+ changed.push({
+ path: file,
+ added: added === "-" ? 0 : parseInt(added, 10),
+ removed: removed === "-" ? 0 : parseInt(removed, 10),
+ status: "modified",
+ })
+ }
+ }
+
+ const untrackedOutput = (
+ await git(
+ [
+ "-c",
+ "core.fsmonitor=false",
+ "-c",
+ "core.quotepath=false",
+ "ls-files",
+ "--others",
+ "--exclude-standard",
+ ],
+ {
+ cwd: Instance.directory,
+ },
+ )
+ ).text()
+
+ if (untrackedOutput.trim()) {
+ for (const file of untrackedOutput.trim().split("\n")) {
+ try {
+ const content = await Filesystem.readText(path.join(Instance.directory, file))
+ changed.push({
+ path: file,
+ added: content.split("\n").length,
+ removed: 0,
+ status: "added",
+ })
+ } catch {
+ continue
+ }
+ }
+ }
+
+ const deletedOutput = (
+ await git(
+ [
+ "-c",
+ "core.fsmonitor=false",
+ "-c",
+ "core.quotepath=false",
+ "diff",
+ "--name-only",
+ "--diff-filter=D",
+ "HEAD",
+ ],
+ {
+ cwd: Instance.directory,
+ },
+ )
+ ).text()
+
+ if (deletedOutput.trim()) {
+ for (const file of deletedOutput.trim().split("\n")) {
+ changed.push({
+ path: file,
+ added: 0,
+ removed: 0,
+ status: "deleted",
+ })
+ }
+ }
+
+ return changed.map((item) => {
+ const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
+ return {
+ ...item,
+ path: path.relative(Instance.directory, full),
+ }
+ })
+ })
+ })
+
+ const read = Effect.fn("File.read")(function* (file: string) {
+ return yield* Effect.promise(async (): Promise<File.Content> => {
+ using _ = log.time("read", { file })
+ const full = path.join(Instance.directory, file)
+
+ if (!Instance.containsPath(full)) {
+ throw new Error("Access denied: path escapes project directory")
+ }
+
+ if (isImageByExtension(file)) {
+ if (await Filesystem.exists(full)) {
+ const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+ return {
+ type: "text",
+ content: buffer.toString("base64"),
+ mimeType: getImageMimeType(file),
+ encoding: "base64",
+ }
+ }
+ return { type: "text", content: "" }
+ }
+
+ const knownText = isTextByExtension(file) || isTextByName(file)
+
+ if (isBinaryByExtension(file) && !knownText) {
+ return { type: "binary", content: "" }
+ }
+
+ if (!(await Filesystem.exists(full))) {
+ return { type: "text", content: "" }
+ }
+
+ const mimeType = Filesystem.mimeType(full)
+ const encode = knownText ? false : shouldEncode(mimeType)
+
+ if (encode && !isImage(mimeType)) {
+ return { type: "binary", content: "", mimeType }
+ }
+
+ if (encode) {
+ const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
+ return {
+ type: "text",
+ content: buffer.toString("base64"),
+ mimeType,
+ encoding: "base64",
+ }
+ }
+
+ const content = (await Filesystem.readText(full).catch(() => "")).trim()
+
+ if (Instance.project.vcs === "git") {
+ let diff = (
+ await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
+ ).text()
+ if (!diff.trim()) {
+ diff = (
+ await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+ cwd: Instance.directory,
+ })
+ ).text()
+ }
+ if (diff.trim()) {
+ const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
+ const patch = structuredPatch(file, file, original, content, "old", "new", {
+ context: Infinity,
+ ignoreWhitespace: true,
+ })
+ return {
+ type: "text",
+ content,
+ patch,
+ diff: formatPatch(patch),
+ }
+ }
+ }
+
+ return { type: "text", content }
+ })
+ })
+
+ const list = Effect.fn("File.list")(function* (dir?: string) {
+ return yield* Effect.promise(async () => {
+ const exclude = [".git", ".DS_Store"]
+ let ignored = (_: string) => false
+ if (Instance.project.vcs === "git") {
+ const ig = ignore()
+ const gitignore = path.join(Instance.project.worktree, ".gitignore")
+ if (await Filesystem.exists(gitignore)) {
+ ig.add(await Filesystem.readText(gitignore))
+ }
+ const ignoreFile = path.join(Instance.project.worktree, ".ignore")
+ if (await Filesystem.exists(ignoreFile)) {
+ ig.add(await Filesystem.readText(ignoreFile))
+ }
+ ignored = ig.ignores.bind(ig)
+ }
+
+ const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
+ if (!Instance.containsPath(resolved)) {
+ throw new Error("Access denied: path escapes project directory")
+ }
+
+ const nodes: File.Node[] = []
+ for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
+ if (exclude.includes(entry.name)) continue
+ const absolute = path.join(resolved, entry.name)
+ const file = path.relative(Instance.directory, absolute)
+ const type = entry.isDirectory() ? "directory" : "file"
+ nodes.push({
+ name: entry.name,
+ path: file,
+ absolute,
+ type,
+ ignored: ignored(type === "directory" ? file + "/" : file),
+ })
+ }
+
+ return nodes.sort((a, b) => {
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1
+ return a.name.localeCompare(b.name)
+ })
+ })
+ })
+
+ const search = Effect.fn("File.search")(function* (input: {
+ query: string
+ limit?: number
+ dirs?: boolean
+ type?: "file" | "directory"
+ }) {
+ yield* ensure()
+ const { cache } = yield* InstanceState.get(state)
+
+ return yield* Effect.promise(async () => {
+ const query = input.query.trim()
+ const limit = input.limit ?? 100
+ const kind = input.type ?? (input.dirs === false ? "file" : "all")
+ log.info("search", { query, kind })
+
+ const result = cache
+ const preferHidden = query.startsWith(".") || query.includes("/.")
+
+ if (!query) {
+ if (kind === "file") return result.files.slice(0, limit)
+ return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
+ }
+
+ const items =
+ kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
+
+ const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
+ const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
+ const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
+
+ log.info("search", { query, kind, results: output.length })
+ return output
+ })
+ })
+
+ log.info("init")
+ return Service.of({ init, status, read, list, search })
+ }),
+ )
- export const Service = S.Service
- export const layer = S.layer
+ const runPromise = makeRunPromise(Service, layer)
export function init() {
- return runPromiseInstance(S.Service.use((svc) => svc.init()))
+ return runPromise((svc) => svc.init())
}
export async function status() {
- return runPromiseInstance(S.Service.use((svc) => svc.status()))
+ return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
- return runPromiseInstance(S.Service.use((svc) => svc.read(file)))
+ return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
- return runPromiseInstance(S.Service.use((svc) => svc.list(dir)))
+ return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
- return runPromiseInstance(S.Service.use((svc) => svc.search(input)))
+ return runPromise((svc) => svc.search(input))
}
}
diff --git a/packages/opencode/src/file/service.ts b/packages/opencode/src/file/service.ts
deleted file mode 100644
index d4f6b347f..000000000
--- a/packages/opencode/src/file/service.ts
+++ /dev/null
@@ -1,674 +0,0 @@
-import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
-import { git } from "@/util/git"
-import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
-import { formatPatch, structuredPatch } from "diff"
-import fs from "fs"
-import fuzzysort from "fuzzysort"
-import ignore from "ignore"
-import path from "path"
-import z from "zod"
-import { Global } from "../global"
-import { Instance } from "../project/instance"
-import { Filesystem } from "../util/filesystem"
-import { Log } from "../util/log"
-import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
-
-export namespace File {
- export const Info = z
- .object({
- path: z.string(),
- added: z.number().int(),
- removed: z.number().int(),
- status: z.enum(["added", "deleted", "modified"]),
- })
- .meta({
- ref: "File",
- })
-
- export type Info = z.infer<typeof Info>
-
- export const Node = z
- .object({
- name: z.string(),
- path: z.string(),
- absolute: z.string(),
- type: z.enum(["file", "directory"]),
- ignored: z.boolean(),
- })
- .meta({
- ref: "FileNode",
- })
- export type Node = z.infer<typeof Node>
-
- export const Content = z
- .object({
- type: z.enum(["text", "binary"]),
- content: z.string(),
- diff: z.string().optional(),
- patch: z
- .object({
- oldFileName: z.string(),
- newFileName: z.string(),
- oldHeader: z.string().optional(),
- newHeader: z.string().optional(),
- hunks: z.array(
- z.object({
- oldStart: z.number(),
- oldLines: z.number(),
- newStart: z.number(),
- newLines: z.number(),
- lines: z.array(z.string()),
- }),
- ),
- index: z.string().optional(),
- })
- .optional(),
- encoding: z.literal("base64").optional(),
- mimeType: z.string().optional(),
- })
- .meta({
- ref: "FileContent",
- })
- export type Content = z.infer<typeof Content>
-
- export const Event = {
- Edited: BusEvent.define(
- "file.edited",
- z.object({
- file: z.string(),
- }),
- ),
- }
-
- const log = Log.create({ service: "file" })
-
- const binary = new Set([
- "exe",
- "dll",
- "pdb",
- "bin",
- "so",
- "dylib",
- "o",
- "a",
- "lib",
- "wav",
- "mp3",
- "ogg",
- "oga",
- "ogv",
- "ogx",
- "flac",
- "aac",
- "wma",
- "m4a",
- "weba",
- "mp4",
- "avi",
- "mov",
- "wmv",
- "flv",
- "webm",
- "mkv",
- "zip",
- "tar",
- "gz",
- "gzip",
- "bz",
- "bz2",
- "bzip",
- "bzip2",
- "7z",
- "rar",
- "xz",
- "lz",
- "z",
- "pdf",
- "doc",
- "docx",
- "ppt",
- "pptx",
- "xls",
- "xlsx",
- "dmg",
- "iso",
- "img",
- "vmdk",
- "ttf",
- "otf",
- "woff",
- "woff2",
- "eot",
- "sqlite",
- "db",
- "mdb",
- "apk",
- "ipa",
- "aab",
- "xapk",
- "app",
- "pkg",
- "deb",
- "rpm",
- "snap",
- "flatpak",
- "appimage",
- "msi",
- "msp",
- "jar",
- "war",
- "ear",
- "class",
- "kotlin_module",
- "dex",
- "vdex",
- "odex",
- "oat",
- "art",
- "wasm",
- "wat",
- "bc",
- "ll",
- "s",
- "ko",
- "sys",
- "drv",
- "efi",
- "rom",
- "com",
- "cmd",
- "ps1",
- "sh",
- "bash",
- "zsh",
- "fish",
- ])
-
- const image = new Set([
- "png",
- "jpg",
- "jpeg",
- "gif",
- "bmp",
- "webp",
- "ico",
- "tif",
- "tiff",
- "svg",
- "svgz",
- "avif",
- "apng",
- "jxl",
- "heic",
- "heif",
- "raw",
- "cr2",
- "nef",
- "arw",
- "dng",
- "orf",
- "raf",
- "pef",
- "x3f",
- ])
-
- const text = new Set([
- "ts",
- "tsx",
- "mts",
- "cts",
- "mtsx",
- "ctsx",
- "js",
- "jsx",
- "mjs",
- "cjs",
- "sh",
- "bash",
- "zsh",
- "fish",
- "ps1",
- "psm1",
- "cmd",
- "bat",
- "json",
- "jsonc",
- "json5",
- "yaml",
- "yml",
- "toml",
- "md",
- "mdx",
- "txt",
- "xml",
- "html",
- "htm",
- "css",
- "scss",
- "sass",
- "less",
- "graphql",
- "gql",
- "sql",
- "ini",
- "cfg",
- "conf",
- "env",
- ])
-
- const textName = new Set([
- "dockerfile",
- "makefile",
- ".gitignore",
- ".gitattributes",
- ".editorconfig",
- ".npmrc",
- ".nvmrc",
- ".prettierrc",
- ".eslintrc",
- ])
-
- const mime: Record<string, string> = {
- png: "image/png",
- jpg: "image/jpeg",
- jpeg: "image/jpeg",
- gif: "image/gif",
- bmp: "image/bmp",
- webp: "image/webp",
- ico: "image/x-icon",
- tif: "image/tiff",
- tiff: "image/tiff",
- svg: "image/svg+xml",
- svgz: "image/svg+xml",
- avif: "image/avif",
- apng: "image/apng",
- jxl: "image/jxl",
- heic: "image/heic",
- heif: "image/heif",
- }
-
- type Entry = { files: string[]; dirs: string[] }
-
- const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
- const name = (file: string) => path.basename(file).toLowerCase()
- const isImageByExtension = (file: string) => image.has(ext(file))
- const isTextByExtension = (file: string) => text.has(ext(file))
- const isTextByName = (file: string) => textName.has(name(file))
- const isBinaryByExtension = (file: string) => binary.has(ext(file))
- const isImage = (mimeType: string) => mimeType.startsWith("image/")
- const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
-
- function shouldEncode(mimeType: string) {
- const type = mimeType.toLowerCase()
- log.info("shouldEncode", { type })
- if (!type) return false
- if (type.startsWith("text/")) return false
- if (type.includes("charset=")) return false
- const top = type.split("/", 2)[0]
- return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
- }
-
- const hidden = (item: string) => {
- const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
- return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
- }
-
- const sortHiddenLast = (items: string[], prefer: boolean) => {
- if (prefer) return items
- const visible: string[] = []
- const hiddenItems: string[] = []
- for (const item of items) {
- if (hidden(item)) hiddenItems.push(item)
- else visible.push(item)
- }
- return [...visible, ...hiddenItems]
- }
-
- export interface Interface {
- readonly init: () => Effect.Effect<void>
- readonly status: () => Effect.Effect<File.Info[]>
- readonly read: (file: string) => Effect.Effect<File.Content>
- readonly list: (dir?: string) => Effect.Effect<File.Node[]>
- readonly search: (input: {
- query: string
- limit?: number
- dirs?: boolean
- type?: "file" | "directory"
- }) => Effect.Effect<string[]>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const instance = yield* InstanceContext
- let cache: Entry = { files: [], dirs: [] }
- const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
-
- const scan = Effect.fn("File.scan")(function* () {
- if (instance.directory === path.parse(instance.directory).root) return
- const next: Entry = { files: [], dirs: [] }
-
- yield* Effect.promise(async () => {
- if (isGlobalHome) {
- const dirs = new Set<string>()
- const protectedNames = Protected.names()
- const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
- const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
- const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
- const top = await fs.promises
- .readdir(instance.directory, { withFileTypes: true })
- .catch(() => [] as fs.Dirent[])
-
- for (const entry of top) {
- if (!entry.isDirectory()) continue
- if (shouldIgnoreName(entry.name)) continue
- dirs.add(entry.name + "/")
-
- const base = path.join(instance.directory, entry.name)
- const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
- for (const child of children) {
- if (!child.isDirectory()) continue
- if (shouldIgnoreNested(child.name)) continue
- dirs.add(entry.name + "/" + child.name + "/")
- }
- }
-
- next.dirs = Array.from(dirs).toSorted()
- } else {
- const seen = new Set<string>()
- for await (const file of Ripgrep.files({ cwd: instance.directory })) {
- next.files.push(file)
- let current = file
- while (true) {
- const dir = path.dirname(current)
- if (dir === ".") break
- if (dir === current) break
- current = dir
- if (seen.has(dir)) continue
- seen.add(dir)
- next.dirs.push(dir + "/")
- }
- }
- }
- })
-
- cache = next
- })
-
- const getFiles = () => cache
-
- const scope = yield* Scope.Scope
- let fiber: Fiber.Fiber<void> | undefined
-
- const init = Effect.fn("File.init")(function* () {
- if (!fiber) {
- fiber = yield* scan().pipe(
- Effect.catchCause(() => Effect.void),
- Effect.forkIn(scope),
- )
- }
- yield* Fiber.join(fiber)
- })
-
- const status = Effect.fn("File.status")(function* () {
- if (instance.project.vcs !== "git") return []
-
- return yield* Effect.promise(async () => {
- const diffOutput = (
- await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
- cwd: instance.directory,
- })
- ).text()
-
- const changed: File.Info[] = []
-
- if (diffOutput.trim()) {
- for (const line of diffOutput.trim().split("\n")) {
- const [added, removed, file] = line.split("\t")
- changed.push({
- path: file,
- added: added === "-" ? 0 : parseInt(added, 10),
- removed: removed === "-" ? 0 : parseInt(removed, 10),
- status: "modified",
- })
- }
- }
-
- const untrackedOutput = (
- await git(
- [
- "-c",
- "core.fsmonitor=false",
- "-c",
- "core.quotepath=false",
- "ls-files",
- "--others",
- "--exclude-standard",
- ],
- {
- cwd: instance.directory,
- },
- )
- ).text()
-
- if (untrackedOutput.trim()) {
- for (const file of untrackedOutput.trim().split("\n")) {
- try {
- const content = await Filesystem.readText(path.join(instance.directory, file))
- changed.push({
- path: file,
- added: content.split("\n").length,
- removed: 0,
- status: "added",
- })
- } catch {
- continue
- }
- }
- }
-
- const deletedOutput = (
- await git(
- [
- "-c",
- "core.fsmonitor=false",
- "-c",
- "core.quotepath=false",
- "diff",
- "--name-only",
- "--diff-filter=D",
- "HEAD",
- ],
- {
- cwd: instance.directory,
- },
- )
- ).text()
-
- if (deletedOutput.trim()) {
- for (const file of deletedOutput.trim().split("\n")) {
- changed.push({
- path: file,
- added: 0,
- removed: 0,
- status: "deleted",
- })
- }
- }
-
- return changed.map((item) => {
- const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
- return {
- ...item,
- path: path.relative(instance.directory, full),
- }
- })
- })
- })
-
- const read = Effect.fn("File.read")(function* (file: string) {
- return yield* Effect.promise(async (): Promise<File.Content> => {
- using _ = log.time("read", { file })
- const full = path.join(instance.directory, file)
-
- if (!Instance.containsPath(full)) {
- throw new Error("Access denied: path escapes project directory")
- }
-
- if (isImageByExtension(file)) {
- if (await Filesystem.exists(full)) {
- const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
- return {
- type: "text",
- content: buffer.toString("base64"),
- mimeType: getImageMimeType(file),
- encoding: "base64",
- }
- }
- return { type: "text", content: "" }
- }
-
- const knownText = isTextByExtension(file) || isTextByName(file)
-
- if (isBinaryByExtension(file) && !knownText) {
- return { type: "binary", content: "" }
- }
-
- if (!(await Filesystem.exists(full))) {
- return { type: "text", content: "" }
- }
-
- const mimeType = Filesystem.mimeType(full)
- const encode = knownText ? false : shouldEncode(mimeType)
-
- if (encode && !isImage(mimeType)) {
- return { type: "binary", content: "", mimeType }
- }
-
- if (encode) {
- const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
- return {
- type: "text",
- content: buffer.toString("base64"),
- mimeType,
- encoding: "base64",
- }
- }
-
- const content = (await Filesystem.readText(full).catch(() => "")).trim()
-
- if (instance.project.vcs === "git") {
- let diff = (
- await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory })
- ).text()
- if (!diff.trim()) {
- diff = (
- await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
- cwd: instance.directory,
- })
- ).text()
- }
- if (diff.trim()) {
- const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text()
- const patch = structuredPatch(file, file, original, content, "old", "new", {
- context: Infinity,
- ignoreWhitespace: true,
- })
- return {
- type: "text",
- content,
- patch,
- diff: formatPatch(patch),
- }
- }
- }
-
- return { type: "text", content }
- })
- })
-
- const list = Effect.fn("File.list")(function* (dir?: string) {
- return yield* Effect.promise(async () => {
- const exclude = [".git", ".DS_Store"]
- let ignored = (_: string) => false
- if (instance.project.vcs === "git") {
- const ig = ignore()
- const gitignore = path.join(instance.project.worktree, ".gitignore")
- if (await Filesystem.exists(gitignore)) {
- ig.add(await Filesystem.readText(gitignore))
- }
- const ignoreFile = path.join(instance.project.worktree, ".ignore")
- if (await Filesystem.exists(ignoreFile)) {
- ig.add(await Filesystem.readText(ignoreFile))
- }
- ignored = ig.ignores.bind(ig)
- }
-
- const resolved = dir ? path.join(instance.directory, dir) : instance.directory
- if (!Instance.containsPath(resolved)) {
- throw new Error("Access denied: path escapes project directory")
- }
-
- const nodes: File.Node[] = []
- for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
- if (exclude.includes(entry.name)) continue
- const absolute = path.join(resolved, entry.name)
- const file = path.relative(instance.directory, absolute)
- const type = entry.isDirectory() ? "directory" : "file"
- nodes.push({
- name: entry.name,
- path: file,
- absolute,
- type,
- ignored: ignored(type === "directory" ? file + "/" : file),
- })
- }
-
- return nodes.sort((a, b) => {
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1
- return a.name.localeCompare(b.name)
- })
- })
- })
-
- const search = Effect.fn("File.search")(function* (input: {
- query: string
- limit?: number
- dirs?: boolean
- type?: "file" | "directory"
- }) {
- return yield* Effect.promise(async () => {
- const query = input.query.trim()
- const limit = input.limit ?? 100
- const kind = input.type ?? (input.dirs === false ? "file" : "all")
- log.info("search", { query, kind })
-
- const result = getFiles()
- const preferHidden = query.startsWith(".") || query.includes("/.")
-
- if (!query) {
- if (kind === "file") return result.files.slice(0, limit)
- return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
- }
-
- const items =
- kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
-
- const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
- const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
- const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
-
- log.info("search", { query, kind, results: output.length })
- return output
- })
- })
-
- log.info("init")
- return Service.of({ init, status, read, list, search })
- }),
- ).pipe(Layer.fresh)
-}
diff --git a/packages/opencode/src/file/time-service.ts b/packages/opencode/src/file/time-service.ts
deleted file mode 100644
index a0fa8bfab..000000000
--- a/packages/opencode/src/file/time-service.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
-import { Flag } from "@/flag/flag"
-import type { SessionID } from "@/session/schema"
-import { Filesystem } from "../util/filesystem"
-import { Log } from "../util/log"
-
-export namespace FileTime {
- const log = Log.create({ service: "file.time" })
-
- export type Stamp = {
- readonly read: Date
- readonly mtime: number | undefined
- readonly ctime: number | undefined
- readonly size: number | undefined
- }
-
- const stamp = Effect.fnUntraced(function* (file: string) {
- const stat = Filesystem.stat(file)
- const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
- return {
- read: yield* DateTime.nowAsDate,
- mtime: stat?.mtime?.getTime(),
- ctime: stat?.ctime?.getTime(),
- size,
- }
- })
-
- const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
- const value = reads.get(sessionID)
- if (value) return value
-
- const next = new Map<string, Stamp>()
- reads.set(sessionID, next)
- return next
- }
-
- export interface Interface {
- readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
- readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
- readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
- readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
- const reads = new Map<SessionID, Map<string, Stamp>>()
- const locks = new Map<string, Semaphore.Semaphore>()
-
- const getLock = (filepath: string) => {
- const lock = locks.get(filepath)
- if (lock) return lock
-
- const next = Semaphore.makeUnsafe(1)
- locks.set(filepath, next)
- return next
- }
-
- const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
- log.info("read", { sessionID, file })
- session(reads, sessionID).set(file, yield* stamp(file))
- })
-
- const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
- return reads.get(sessionID)?.get(file)?.read
- })
-
- const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
- if (disableCheck) return
-
- const time = reads.get(sessionID)?.get(filepath)
- if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
-
- const next = yield* stamp(filepath)
- const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
- if (!changed) return
-
- throw new Error(
- `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
- )
- })
-
- const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
- return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
- })
-
- return Service.of({ read, get, assert, withLock })
- }),
- ).pipe(Layer.orDie, Layer.fresh)
-}
diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts
index b6d572fe8..4962ef0c9 100644
--- a/packages/opencode/src/file/time.ts
+++ b/packages/opencode/src/file/time.ts
@@ -1,28 +1,128 @@
-import { runPromiseInstance } from "@/effect/runtime"
+import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
-import { FileTime as S } from "./time-service"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
export namespace FileTime {
- export type Stamp = S.Stamp
+ const log = Log.create({ service: "file.time" })
- export type Interface = S.Interface
+ export type Stamp = {
+ readonly read: Date
+ readonly mtime: number | undefined
+ readonly ctime: number | undefined
+ readonly size: number | undefined
+ }
+
+ const stamp = Effect.fnUntraced(function* (file: string) {
+ const stat = Filesystem.stat(file)
+ const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
+ return {
+ read: yield* DateTime.nowAsDate,
+ mtime: stat?.mtime?.getTime(),
+ ctime: stat?.ctime?.getTime(),
+ size,
+ }
+ })
+
+ const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
+ const value = reads.get(sessionID)
+ if (value) return value
+
+ const next = new Map<string, Stamp>()
+ reads.set(sessionID, next)
+ return next
+ }
+
+ interface State {
+ reads: Map<SessionID, Map<string, Stamp>>
+ locks: Map<string, Semaphore.Semaphore>
+ }
+
+ export interface Interface {
+ readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
+ readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
+ readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
+ readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("FileTime.state")(() =>
+ Effect.succeed({
+ reads: new Map<SessionID, Map<string, Stamp>>(),
+ locks: new Map<string, Semaphore.Semaphore>(),
+ }),
+ ),
+ )
+
+ const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
+ const locks = (yield* InstanceState.get(state)).locks
+ const lock = locks.get(filepath)
+ if (lock) return lock
+
+ const next = Semaphore.makeUnsafe(1)
+ locks.set(filepath, next)
+ return next
+ })
+
+ const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
+ const reads = (yield* InstanceState.get(state)).reads
+ log.info("read", { sessionID, file })
+ session(reads, sessionID).set(file, yield* stamp(file))
+ })
+
+ const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
+ const reads = (yield* InstanceState.get(state)).reads
+ return reads.get(sessionID)?.get(file)?.read
+ })
+
+ const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
+ if (disableCheck) return
+
+ const reads = (yield* InstanceState.get(state)).reads
+ const time = reads.get(sessionID)?.get(filepath)
+ if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
+
+ const next = yield* stamp(filepath)
+ const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
+ if (!changed) return
+
+ throw new Error(
+ `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
+ )
+ })
+
+ const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
+ return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
+ })
+
+ return Service.of({ read, get, assert, withLock })
+ }),
+ ).pipe(Layer.orDie)
- export const Service = S.Service
- export const layer = S.layer
+ const runPromise = makeRunPromise(Service, layer)
export function read(sessionID: SessionID, file: string) {
- return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file)))
+ return runPromise((s) => s.read(sessionID, file))
}
export function get(sessionID: SessionID, file: string) {
- return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file)))
+ return runPromise((s) => s.get(sessionID, file))
}
export async function assert(sessionID: SessionID, filepath: string) {
- return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath)))
+ return runPromise((s) => s.assert(sessionID, filepath))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
- return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn)))
+ return runPromise((s) => s.withLock(filepath, fn))
}
}
diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts
index 7e5f5f7be..1b3fc8ab4 100644
--- a/packages/opencode/src/file/watcher.ts
+++ b/packages/opencode/src/file/watcher.ts
@@ -1,4 +1,4 @@
-import { Cause, Effect, Layer, ServiceMap } from "effect"
+import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
@@ -7,7 +7,8 @@ import path from "path"
import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { git } from "@/util/git"
@@ -60,82 +61,107 @@ export namespace FileWatcher {
export const hasNativeBinding = () => !!watcher()
- export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
+ export interface Interface {
+ readonly init: () => Effect.Effect<void>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const instance = yield* InstanceContext
- if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
-
- log.info("init", { directory: instance.directory })
-
- const backend = getBackend()
- if (!backend) {
- log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
- return Service.of({})
- }
-
- const w = watcher()
- if (!w) return Service.of({})
-
- log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
-
- const subs: ParcelWatcher.AsyncSubscription[] = []
- yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))))
-
- const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
- if (err) return
- for (const evt of evts) {
- if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
- if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
- if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
- }
- })
-
- const subscribe = (dir: string, ignore: string[]) => {
- const pending = w.subscribe(dir, cb, { ignore, backend })
- return Effect.gen(function* () {
- const sub = yield* Effect.promise(() => pending)
- subs.push(sub)
- }).pipe(
- Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
+ const state = yield* InstanceState.make(
+ Effect.fn("FileWatcher.state")(
+ function* () {
+ if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
+
+ log.info("init", { directory: Instance.directory })
+
+ const backend = getBackend()
+ if (!backend) {
+ log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
+ return
+ }
+
+ const w = watcher()
+ if (!w) return
+
+ log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
+
+ const subs: ParcelWatcher.AsyncSubscription[] = []
+ yield* Effect.addFinalizer(() =>
+ Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
+ )
+
+ const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
+ if (err) return
+ for (const evt of evts) {
+ if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
+ if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
+ if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
+ }
+ })
+
+ const subscribe = (dir: string, ignore: string[]) => {
+ const pending = w.subscribe(dir, cb, { ignore, backend })
+ return Effect.gen(function* () {
+ const sub = yield* Effect.promise(() => pending)
+ subs.push(sub)
+ }).pipe(
+ Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
+ Effect.catchCause((cause) => {
+ log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
+ pending.then((s) => s.unsubscribe()).catch(() => {})
+ return Effect.void
+ }),
+ )
+ }
+
+ const cfg = yield* Effect.promise(() => Config.get())
+ const cfgIgnores = cfg.watcher?.ignore ?? []
+
+ if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
+ yield* subscribe(Instance.directory, [
+ ...FileIgnore.PATTERNS,
+ ...cfgIgnores,
+ ...protecteds(Instance.directory),
+ ])
+ }
+
+ if (Instance.project.vcs === "git") {
+ const result = yield* Effect.promise(() =>
+ git(["rev-parse", "--git-dir"], {
+ cwd: Instance.project.worktree,
+ }),
+ )
+ const vcsDir =
+ result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
+ if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
+ const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
+ (entry) => entry !== "HEAD",
+ )
+ yield* subscribe(vcsDir, ignore)
+ }
+ }
+ },
Effect.catchCause((cause) => {
- log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
- pending.then((s) => s.unsubscribe()).catch(() => {})
+ log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
- )
- }
+ ),
+ )
- const cfg = yield* Effect.promise(() => Config.get())
- const cfgIgnores = cfg.watcher?.ignore ?? []
+ return Service.of({
+ init: Effect.fn("FileWatcher.init")(function* () {
+ yield* InstanceState.get(state)
+ }),
+ })
+ }),
+ )
- if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
- yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
- }
+ const runPromise = makeRunPromise(Service, layer)
- if (instance.project.vcs === "git") {
- const result = yield* Effect.promise(() =>
- git(["rev-parse", "--git-dir"], {
- cwd: instance.project.worktree,
- }),
- )
- const vcsDir = result.exitCode === 0 ? path.resolve(instance.project.worktree, result.text().trim()) : undefined
- if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
- const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
- (entry) => entry !== "HEAD",
- )
- yield* subscribe(vcsDir, ignore)
- }
- }
-
- return Service.of({})
- }).pipe(
- Effect.catchCause((cause) => {
- log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
- return Effect.succeed(Service.of({}))
- }),
- ),
- ).pipe(Layer.orDie, Layer.fresh)
+ export function init() {
+ return runPromise((svc) => svc.init())
+ }
}
diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index e4381c69b..39e0630cf 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -1,16 +1,182 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { Format as S } from "./service"
+import { Effect, Layer, ServiceMap } from "effect"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import path from "path"
+import { mergeDeep } from "remeda"
+import z from "zod"
+import { Bus } from "../bus"
+import { Config } from "../config/config"
+import { File } from "../file"
+import { Instance } from "../project/instance"
+import { Process } from "../util/process"
+import { Log } from "../util/log"
+import * as Formatter from "./formatter"
export namespace Format {
- export const Status = S.Status
- export type Status = S.Status
+ const log = Log.create({ service: "format" })
- export type Interface = S.Interface
+ export const Status = z
+ .object({
+ name: z.string(),
+ extensions: z.string().array(),
+ enabled: z.boolean(),
+ })
+ .meta({
+ ref: "FormatterStatus",
+ })
+ export type Status = z.infer<typeof Status>
- export const Service = S.Service
- export const layer = S.layer
+ export interface Interface {
+ readonly init: () => Effect.Effect<void>
+ readonly status: () => Effect.Effect<Status[]>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make(
+ Effect.fn("Format.state")(function* (_ctx) {
+ const enabled: Record<string, boolean> = {}
+ const formatters: Record<string, Formatter.Info> = {}
+
+ const cfg = yield* Effect.promise(() => Config.get())
+
+ if (cfg.formatter !== false) {
+ for (const item of Object.values(Formatter)) {
+ formatters[item.name] = item
+ }
+ for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
+ if (item.disabled) {
+ delete formatters[name]
+ continue
+ }
+ const info = mergeDeep(formatters[name] ?? {}, {
+ command: [],
+ extensions: [],
+ ...item,
+ })
+
+ if (info.command.length === 0) continue
+
+ formatters[name] = {
+ ...info,
+ name,
+ enabled: async () => true,
+ }
+ }
+ } else {
+ log.info("all formatters are disabled")
+ }
+
+ async function isEnabled(item: Formatter.Info) {
+ let status = enabled[item.name]
+ if (status === undefined) {
+ status = await item.enabled()
+ enabled[item.name] = status
+ }
+ return status
+ }
+
+ async function getFormatter(ext: string) {
+ const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
+ const checks = await Promise.all(
+ matching.map(async (item) => {
+ log.info("checking", { name: item.name, ext })
+ const on = await isEnabled(item)
+ if (on) {
+ log.info("enabled", { name: item.name, ext })
+ }
+ return {
+ item,
+ enabled: on,
+ }
+ }),
+ )
+ return checks.filter((x) => x.enabled).map((x) => x.item)
+ }
+
+ yield* Effect.acquireRelease(
+ Effect.sync(() =>
+ Bus.subscribe(
+ File.Event.Edited,
+ Instance.bind(async (payload) => {
+ const file = payload.properties.file
+ log.info("formatting", { file })
+ const ext = path.extname(file)
+
+ for (const item of await getFormatter(ext)) {
+ log.info("running", { command: item.command })
+ try {
+ const proc = Process.spawn(
+ item.command.map((x) => x.replace("$FILE", file)),
+ {
+ cwd: Instance.directory,
+ env: { ...process.env, ...item.environment },
+ stdout: "ignore",
+ stderr: "ignore",
+ },
+ )
+ const exit = await proc.exited
+ if (exit !== 0) {
+ log.error("failed", {
+ command: item.command,
+ ...item.environment,
+ })
+ }
+ } catch (error) {
+ log.error("failed to format file", {
+ error,
+ command: item.command,
+ ...item.environment,
+ file,
+ })
+ }
+ }
+ }),
+ ),
+ ),
+ (unsubscribe) => Effect.sync(unsubscribe),
+ )
+ log.info("init")
+
+ return {
+ formatters,
+ isEnabled,
+ }
+ }),
+ )
+
+ const init = Effect.fn("Format.init")(function* () {
+ yield* InstanceState.get(state)
+ })
+
+ const status = Effect.fn("Format.status")(function* () {
+ const { formatters, isEnabled } = yield* InstanceState.get(state)
+ const result: Status[] = []
+ for (const formatter of Object.values(formatters)) {
+ const isOn = yield* Effect.promise(() => isEnabled(formatter))
+ result.push({
+ name: formatter.name,
+ extensions: formatter.extensions,
+ enabled: isOn,
+ })
+ }
+ return result
+ })
+
+ return Service.of({ init, status })
+ }),
+ )
+
+ const runPromise = makeRunPromise(Service, layer)
+
+ export async function init() {
+ return runPromise((s) => s.init())
+ }
export async function status() {
- return runPromiseInstance(S.Service.use((s) => s.status()))
+ return runPromise((s) => s.status())
}
}
diff --git a/packages/opencode/src/format/service.ts b/packages/opencode/src/format/service.ts
deleted file mode 100644
index 64fff7949..000000000
--- a/packages/opencode/src/format/service.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { Effect, Layer, ServiceMap } from "effect"
-import { InstanceContext } from "@/effect/instance-context"
-import path from "path"
-import { mergeDeep } from "remeda"
-import z from "zod"
-import { Bus } from "../bus"
-import { Config } from "../config/config"
-import { File } from "../file/service"
-import { Instance } from "../project/instance"
-import { Process } from "../util/process"
-import { Log } from "../util/log"
-import * as Formatter from "./formatter"
-
-export namespace Format {
- const log = Log.create({ service: "format" })
-
- export const Status = z
- .object({
- name: z.string(),
- extensions: z.string().array(),
- enabled: z.boolean(),
- })
- .meta({
- ref: "FormatterStatus",
- })
- export type Status = z.infer<typeof Status>
-
- export interface Interface {
- readonly status: () => Effect.Effect<Status[]>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const instance = yield* InstanceContext
-
- const enabled: Record<string, boolean> = {}
- const formatters: Record<string, Formatter.Info> = {}
-
- const cfg = yield* Effect.promise(() => Config.get())
-
- if (cfg.formatter !== false) {
- for (const item of Object.values(Formatter)) {
- formatters[item.name] = item
- }
- for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
- if (item.disabled) {
- delete formatters[name]
- continue
- }
- const info = mergeDeep(formatters[name] ?? {}, {
- command: [],
- extensions: [],
- ...item,
- })
-
- if (info.command.length === 0) continue
-
- formatters[name] = {
- ...info,
- name,
- enabled: async () => true,
- }
- }
- } else {
- log.info("all formatters are disabled")
- }
-
- async function isEnabled(item: Formatter.Info) {
- let status = enabled[item.name]
- if (status === undefined) {
- status = await item.enabled()
- enabled[item.name] = status
- }
- return status
- }
-
- async function getFormatter(ext: string) {
- const result = []
- for (const item of Object.values(formatters)) {
- log.info("checking", { name: item.name, ext })
- if (!item.extensions.includes(ext)) continue
- if (!(await isEnabled(item))) continue
- log.info("enabled", { name: item.name, ext })
- result.push(item)
- }
- return result
- }
-
- yield* Effect.acquireRelease(
- Effect.sync(() =>
- Bus.subscribe(
- File.Event.Edited,
- Instance.bind(async (payload) => {
- const file = payload.properties.file
- log.info("formatting", { file })
- const ext = path.extname(file)
-
- for (const item of await getFormatter(ext)) {
- log.info("running", { command: item.command })
- try {
- const proc = Process.spawn(
- item.command.map((x) => x.replace("$FILE", file)),
- {
- cwd: instance.directory,
- env: { ...process.env, ...item.environment },
- stdout: "ignore",
- stderr: "ignore",
- },
- )
- const exit = await proc.exited
- if (exit !== 0) {
- log.error("failed", {
- command: item.command,
- ...item.environment,
- })
- }
- } catch (error) {
- log.error("failed to format file", {
- error,
- command: item.command,
- ...item.environment,
- file,
- })
- }
- }
- }),
- ),
- ),
- (unsubscribe) => Effect.sync(unsubscribe),
- )
- log.info("init")
-
- const status = Effect.fn("Format.status")(function* () {
- const result: Status[] = []
- for (const formatter of Object.values(formatters)) {
- const isOn = yield* Effect.promise(() => isEnabled(formatter))
- result.push({
- name: formatter.name,
- extensions: formatter.extensions,
- enabled: isOn,
- })
- }
- return result
- })
-
- return Service.of({ status })
- }),
- ).pipe(Layer.fresh)
-}
diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts
index d0bd10329..1e4e45f2c 100644
--- a/packages/opencode/src/installation/index.ts
+++ b/packages/opencode/src/installation/index.ts
@@ -1,6 +1,7 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
@@ -293,7 +294,7 @@ export namespace Installation {
result = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
- throw new Error(`Unknown method: ${m}`)
+ return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!result || result.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || ""
@@ -329,27 +330,21 @@ export namespace Installation {
Layer.provide(NodePath.layer),
)
- // Legacy adapters — dynamic import avoids circular dependency since
- // foundational modules (db.ts, provider/models.ts) import Installation
- // at load time, and runtime transitively loads those same modules.
- async function runPromise<A>(f: (service: Interface) => Effect.Effect<A, any>) {
- const { runtime } = await import("@/effect/runtime")
- return runtime.runPromise(Service.use(f))
- }
+ const runPromise = makeRunPromise(Service, defaultLayer)
- export function info(): Promise<Info> {
+ export async function info(): Promise<Info> {
return runPromise((svc) => svc.info())
}
- export function method(): Promise<Method> {
+ export async function method(): Promise<Method> {
return runPromise((svc) => svc.method())
}
- export function latest(installMethod?: Method): Promise<string> {
+ export async function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
- export function upgrade(m: Method, target: string): Promise<void> {
+ export async function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
}
}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index 01ac76897..63e657018 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -1,52 +1,322 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { fn } from "@/util/fn"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Config } from "@/config/config"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { ProjectID } from "@/project/schema"
+import { Instance } from "@/project/instance"
+import { MessageID, SessionID } from "@/session/schema"
+import { PermissionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage/db"
+import { Log } from "@/util/log"
+import { Wildcard } from "@/util/wildcard"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import os from "os"
import z from "zod"
-import { Permission as S } from "./service"
+import { evaluate as evalRule } from "./evaluate"
+import { PermissionID } from "./schema"
-export namespace PermissionNext {
- export const Action = S.Action
- export type Action = S.Action
+export namespace Permission {
+ const log = Log.create({ service: "permission" })
- export const Rule = S.Rule
- export type Rule = S.Rule
+ export const Action = z.enum(["allow", "deny", "ask"]).meta({
+ ref: "PermissionAction",
+ })
+ export type Action = z.infer<typeof Action>
- export const Ruleset = S.Ruleset
- export type Ruleset = S.Ruleset
+ export const Rule = z
+ .object({
+ permission: z.string(),
+ pattern: z.string(),
+ action: Action,
+ })
+ .meta({
+ ref: "PermissionRule",
+ })
+ export type Rule = z.infer<typeof Rule>
- export const Request = S.Request
- export type Request = S.Request
+ export const Ruleset = Rule.array().meta({
+ ref: "PermissionRuleset",
+ })
+ export type Ruleset = z.infer<typeof Ruleset>
- export const Reply = S.Reply
- export type Reply = S.Reply
+ export const Request = z
+ .object({
+ id: PermissionID.zod,
+ sessionID: SessionID.zod,
+ permission: z.string(),
+ patterns: z.string().array(),
+ metadata: z.record(z.string(), z.any()),
+ always: z.string().array(),
+ tool: z
+ .object({
+ messageID: MessageID.zod,
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "PermissionRequest",
+ })
+ export type Request = z.infer<typeof Request>
- export const Approval = S.Approval
- export type Approval = z.infer<typeof S.Approval>
+ export const Reply = z.enum(["once", "always", "reject"])
+ export type Reply = z.infer<typeof Reply>
- export const Event = S.Event
+ export const Approval = z.object({
+ projectID: ProjectID.zod,
+ patterns: z.string().array(),
+ })
- export const RejectedError = S.RejectedError
- export const CorrectedError = S.CorrectedError
- export const DeniedError = S.DeniedError
- export type Error = S.Error
+ export const Event = {
+ Asked: BusEvent.define("permission.asked", Request),
+ Replied: BusEvent.define(
+ "permission.replied",
+ z.object({
+ sessionID: SessionID.zod,
+ requestID: PermissionID.zod,
+ reply: Reply,
+ }),
+ ),
+ }
+
+ export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+ override get message() {
+ return "The user rejected permission to use this specific tool call."
+ }
+ }
+
+ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+ feedback: Schema.String,
+ }) {
+ override get message() {
+ return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+ }
+ }
+
+ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+ ruleset: Schema.Any,
+ }) {
+ override get message() {
+ return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+ }
+ }
+
+ export type Error = DeniedError | RejectedError | CorrectedError
+
+ export const AskInput = Request.partial({ id: true }).extend({
+ ruleset: Ruleset,
+ })
+
+ export const ReplyInput = z.object({
+ requestID: PermissionID.zod,
+ reply: Reply,
+ message: z.string().optional(),
+ })
+
+ 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[]>
+ }
+
+ interface PendingEntry {
+ info: Request
+ deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+ }
+
+ interface State {
+ pending: Map<PermissionID, PendingEntry>
+ approved: Ruleset
+ }
+
+ export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+ log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
+ return evalRule(permission, pattern, ...rulesets)
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Permission.state")(function* (ctx) {
+ const row = Database.use((db) =>
+ db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
+ )
+ const state = {
+ pending: new Map<PermissionID, PendingEntry>(),
+ approved: row?.data ?? [],
+ }
+
+ yield* Effect.addFinalizer(() =>
+ Effect.gen(function* () {
+ for (const item of state.pending.values()) {
+ yield* Deferred.fail(item.deferred, new RejectedError())
+ }
+ state.pending.clear()
+ }),
+ )
+
+ return state
+ }),
+ )
- export const AskInput = S.AskInput
- export const ReplyInput = S.ReplyInput
+ const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
+ const { approved, pending } = yield* InstanceState.get(state)
+ const { ruleset, ...request } = input
+ let needsAsk = false
- export type Interface = S.Interface
+ for (const pattern of request.patterns) {
+ const rule = evaluate(request.permission, pattern, ruleset, approved)
+ log.info("evaluated", { permission: request.permission, pattern, action: rule })
+ if (rule.action === "deny") {
+ return yield* new DeniedError({
+ ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
+ })
+ }
+ if (rule.action === "allow") continue
+ needsAsk = true
+ }
- export const Service = S.Service
- export const layer = S.layer
+ if (!needsAsk) return
- export const evaluate = S.evaluate
- export const fromConfig = S.fromConfig
- export const merge = S.merge
- export const disabled = S.disabled
+ const id = request.id ?? PermissionID.ascending()
+ const info: Request = {
+ id,
+ ...request,
+ }
+ log.info("asking", { id, permission: info.permission, patterns: info.patterns })
- export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input))))
+ const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+ pending.set(id, { info, deferred })
+ void Bus.publish(Event.Asked, info)
+ return yield* Effect.ensuring(
+ Deferred.await(deferred),
+ Effect.sync(() => {
+ pending.delete(id)
+ }),
+ )
+ })
- export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input))))
+ const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
+ const { approved, pending } = yield* InstanceState.get(state)
+ const existing = pending.get(input.requestID)
+ if (!existing) return
+
+ pending.delete(input.requestID)
+ void Bus.publish(Event.Replied, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ reply: input.reply,
+ })
+
+ if (input.reply === "reject") {
+ yield* Deferred.fail(
+ existing.deferred,
+ input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+ )
+
+ for (const [id, item] of pending.entries()) {
+ if (item.info.sessionID !== existing.info.sessionID) continue
+ pending.delete(id)
+ void Bus.publish(Event.Replied, {
+ sessionID: item.info.sessionID,
+ requestID: item.info.id,
+ reply: "reject",
+ })
+ yield* Deferred.fail(item.deferred, new RejectedError())
+ }
+ return
+ }
+
+ yield* Deferred.succeed(existing.deferred, undefined)
+ if (input.reply === "once") return
+
+ for (const pattern of existing.info.always) {
+ approved.push({
+ permission: existing.info.permission,
+ pattern,
+ action: "allow",
+ })
+ }
+
+ for (const [id, item] of pending.entries()) {
+ if (item.info.sessionID !== existing.info.sessionID) continue
+ const ok = item.info.patterns.every(
+ (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
+ )
+ if (!ok) continue
+ pending.delete(id)
+ void Bus.publish(Event.Replied, {
+ sessionID: item.info.sessionID,
+ requestID: item.info.id,
+ reply: "always",
+ })
+ yield* Deferred.succeed(item.deferred, undefined)
+ }
+ })
+
+ const list = Effect.fn("Permission.list")(function* () {
+ const pending = (yield* InstanceState.get(state)).pending
+ return Array.from(pending.values(), (item) => item.info)
+ })
+
+ return Service.of({ ask, reply, list })
+ }),
+ )
+
+ function expand(pattern: string): string {
+ if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
+ if (pattern === "~") return os.homedir()
+ if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
+ if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
+ return pattern
+ }
+
+ export function fromConfig(permission: Config.Permission) {
+ const ruleset: Ruleset = []
+ for (const [key, value] of Object.entries(permission)) {
+ if (typeof value === "string") {
+ ruleset.push({ permission: key, action: value, pattern: "*" })
+ continue
+ }
+ ruleset.push(
+ ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
+ )
+ }
+ return ruleset
+ }
+
+ export function merge(...rulesets: Ruleset[]): Ruleset {
+ return rulesets.flat()
+ }
+
+ const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
+
+ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
+ const result = new Set<string>()
+ for (const tool of tools) {
+ const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
+ const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
+ if (!rule) continue
+ if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
+ }
+ return result
+ }
+
+ export const runPromise = makeRunPromise(Service, layer)
+
+ export async function ask(input: z.infer<typeof AskInput>) {
+ return runPromise((s) => s.ask(input))
+ }
+
+ export async function reply(input: z.infer<typeof ReplyInput>) {
+ return runPromise((s) => s.reply(input))
+ }
export async function list() {
- return runPromiseInstance(S.Service.use((s) => s.list()))
+ return runPromise((s) => s.list())
}
}
diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts
deleted file mode 100644
index 08475520b..000000000
--- a/packages/opencode/src/permission/service.ts
+++ /dev/null
@@ -1,282 +0,0 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-import { Config } from "@/config/config"
-import { InstanceContext } from "@/effect/instance-context"
-import { ProjectID } from "@/project/schema"
-import { MessageID, SessionID } from "@/session/schema"
-import { PermissionTable } from "@/session/session.sql"
-import { Database, eq } from "@/storage/db"
-import { Log } from "@/util/log"
-import { Wildcard } from "@/util/wildcard"
-import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
-import os from "os"
-import z from "zod"
-import { evaluate as evalRule } from "./evaluate"
-import { PermissionID } from "./schema"
-
-export namespace Permission {
- const log = Log.create({ service: "permission" })
-
- export const Action = z.enum(["allow", "deny", "ask"]).meta({
- ref: "PermissionAction",
- })
- export type Action = z.infer<typeof Action>
-
- export const Rule = z
- .object({
- permission: z.string(),
- pattern: z.string(),
- action: Action,
- })
- .meta({
- ref: "PermissionRule",
- })
- export type Rule = z.infer<typeof Rule>
-
- export const Ruleset = Rule.array().meta({
- ref: "PermissionRuleset",
- })
- export type Ruleset = z.infer<typeof Ruleset>
-
- export const Request = z
- .object({
- id: PermissionID.zod,
- sessionID: SessionID.zod,
- permission: z.string(),
- patterns: z.string().array(),
- metadata: z.record(z.string(), z.any()),
- always: z.string().array(),
- tool: z
- .object({
- messageID: MessageID.zod,
- callID: z.string(),
- })
- .optional(),
- })
- .meta({
- ref: "PermissionRequest",
- })
- export type Request = z.infer<typeof Request>
-
- export const Reply = z.enum(["once", "always", "reject"])
- export type Reply = z.infer<typeof Reply>
-
- export const Approval = z.object({
- projectID: ProjectID.zod,
- patterns: z.string().array(),
- })
-
- export const Event = {
- Asked: BusEvent.define("permission.asked", Request),
- Replied: BusEvent.define(
- "permission.replied",
- z.object({
- sessionID: SessionID.zod,
- requestID: PermissionID.zod,
- reply: Reply,
- }),
- ),
- }
-
- export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
- override get message() {
- return "The user rejected permission to use this specific tool call."
- }
- }
-
- export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
- feedback: Schema.String,
- }) {
- override get message() {
- return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
- }
- }
-
- export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
- ruleset: Schema.Any,
- }) {
- override get message() {
- return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
- }
- }
-
- export type Error = DeniedError | RejectedError | CorrectedError
-
- export const AskInput = Request.partial({ id: true }).extend({
- ruleset: Ruleset,
- })
-
- export const ReplyInput = z.object({
- requestID: PermissionID.zod,
- reply: Reply,
- message: z.string().optional(),
- })
-
- 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[]>
- }
-
- interface PendingEntry {
- info: Request
- deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
- }
-
- export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
- log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
- return evalRule(permission, pattern, ...rulesets)
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const { project } = yield* InstanceContext
- const row = Database.use((db) =>
- db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
- )
- const pending = new Map<PermissionID, PendingEntry>()
- const approved: Ruleset = row?.data ?? []
-
- const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
- const { ruleset, ...request } = input
- let needsAsk = false
-
- for (const pattern of request.patterns) {
- const rule = evaluate(request.permission, pattern, ruleset, approved)
- log.info("evaluated", { permission: request.permission, pattern, action: rule })
- if (rule.action === "deny") {
- return yield* new DeniedError({
- ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
- })
- }
- if (rule.action === "allow") continue
- needsAsk = true
- }
-
- if (!needsAsk) return
-
- const id = request.id ?? PermissionID.ascending()
- const info: Request = {
- id,
- ...request,
- }
- log.info("asking", { id, permission: info.permission, patterns: info.patterns })
-
- const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
- pending.set(id, { info, deferred })
- void Bus.publish(Event.Asked, info)
- return yield* Effect.ensuring(
- Deferred.await(deferred),
- Effect.sync(() => {
- pending.delete(id)
- }),
- )
- })
-
- const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
- const existing = pending.get(input.requestID)
- if (!existing) return
-
- pending.delete(input.requestID)
- void Bus.publish(Event.Replied, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- reply: input.reply,
- })
-
- if (input.reply === "reject") {
- yield* Deferred.fail(
- existing.deferred,
- input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
- )
-
- for (const [id, item] of pending.entries()) {
- if (item.info.sessionID !== existing.info.sessionID) continue
- pending.delete(id)
- void Bus.publish(Event.Replied, {
- sessionID: item.info.sessionID,
- requestID: item.info.id,
- reply: "reject",
- })
- yield* Deferred.fail(item.deferred, new RejectedError())
- }
- return
- }
-
- yield* Deferred.succeed(existing.deferred, undefined)
- if (input.reply === "once") return
-
- for (const pattern of existing.info.always) {
- approved.push({
- permission: existing.info.permission,
- pattern,
- action: "allow",
- })
- }
-
- for (const [id, item] of pending.entries()) {
- if (item.info.sessionID !== existing.info.sessionID) continue
- const ok = item.info.patterns.every(
- (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
- )
- if (!ok) continue
- pending.delete(id)
- void Bus.publish(Event.Replied, {
- sessionID: item.info.sessionID,
- requestID: item.info.id,
- reply: "always",
- })
- yield* Deferred.succeed(item.deferred, undefined)
- }
- })
-
- const list = Effect.fn("Permission.list")(function* () {
- return Array.from(pending.values(), (item) => item.info)
- })
-
- return Service.of({ ask, reply, list })
- }),
- ).pipe(Layer.fresh)
-
- function expand(pattern: string): string {
- if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
- if (pattern === "~") return os.homedir()
- if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
- if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
- return pattern
- }
-
- export function fromConfig(permission: Config.Permission) {
- const ruleset: Ruleset = []
- for (const [key, value] of Object.entries(permission)) {
- if (typeof value === "string") {
- ruleset.push({ permission: key, action: value, pattern: "*" })
- continue
- }
- ruleset.push(
- ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
- )
- }
- return ruleset
- }
-
- export function merge(...rulesets: Ruleset[]): Ruleset {
- return rulesets.flat()
- }
-
- const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
-
- export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
- const result = new Set<string>()
- for (const tool of tools) {
- const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
- const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
- if (!rule) continue
- if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
- }
- return result
- }
-}
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index 86403f3da..a8ad84297 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -1,7 +1,11 @@
import { Plugin } from "../plugin"
+import { Format } from "../format"
import { LSP } from "../lsp"
import { File } from "../file"
+import { FileWatcher } from "../file/watcher"
+import { Snapshot } from "../snapshot"
import { Project } from "./project"
+import { Vcs } from "./vcs"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
@@ -12,8 +16,12 @@ export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
ShareNext.init()
+ Format.init()
await LSP.init()
File.init()
+ FileWatcher.init()
+ Vcs.init()
+ Snapshot.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 607554016..4c9b2e107 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -7,13 +7,13 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
-interface Context {
+export interface Shape {
directory: string
worktree: string
project: Project.Info
}
-const context = Context.create<Context>("instance")
-const cache = new Map<string, Promise<Context>>()
+const context = Context.create<Shape>("instance")
+const cache = new Map<string, Promise<Shape>>()
const disposal = {
all: undefined as Promise<void> | undefined,
@@ -52,7 +52,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
})
}
-function track(directory: string, next: Promise<Context>) {
+function track(directory: string, next: Promise<Shape>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
index 9a9e42ecf..dea25b91b 100644
--- a/packages/opencode/src/project/vcs.ts
+++ b/packages/opencode/src/project/vcs.ts
@@ -1,7 +1,8 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
-import { InstanceContext } from "@/effect/instance-context"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git"
@@ -30,54 +31,81 @@ export namespace Vcs {
export type Info = z.infer<typeof Info>
export interface Interface {
+ readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
}
+ interface State {
+ current: string | undefined
+ }
+
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const instance = yield* InstanceContext
- let currentBranch: string | undefined
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Vcs.state")((ctx) =>
+ Effect.gen(function* () {
+ if (ctx.project.vcs !== "git") {
+ return { current: undefined }
+ }
+
+ const getCurrentBranch = async () => {
+ const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
+ cwd: ctx.worktree,
+ })
+ if (result.exitCode !== 0) return undefined
+ const text = result.text().trim()
+ return text || undefined
+ }
- if (instance.project.vcs === "git") {
- const getCurrentBranch = async () => {
- const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
- cwd: instance.project.worktree,
- })
- if (result.exitCode !== 0) return undefined
- const text = result.text().trim()
- return text || undefined
- }
+ const value = {
+ current: yield* Effect.promise(() => getCurrentBranch()),
+ }
+ log.info("initialized", { branch: value.current })
- currentBranch = yield* Effect.promise(() => getCurrentBranch())
- log.info("initialized", { branch: currentBranch })
+ yield* Effect.acquireRelease(
+ Effect.sync(() =>
+ Bus.subscribe(
+ FileWatcher.Event.Updated,
+ Instance.bind(async (evt) => {
+ if (!evt.properties.file.endsWith("HEAD")) return
+ const next = await getCurrentBranch()
+ if (next !== value.current) {
+ log.info("branch changed", { from: value.current, to: next })
+ value.current = next
+ Bus.publish(Event.BranchUpdated, { branch: next })
+ }
+ }),
+ ),
+ ),
+ (unsubscribe) => Effect.sync(unsubscribe),
+ )
- yield* Effect.acquireRelease(
- Effect.sync(() =>
- Bus.subscribe(
- FileWatcher.Event.Updated,
- Instance.bind(async (evt) => {
- if (!evt.properties.file.endsWith("HEAD")) return
- const next = await getCurrentBranch()
- if (next !== currentBranch) {
- log.info("branch changed", { from: currentBranch, to: next })
- currentBranch = next
- Bus.publish(Event.BranchUpdated, { branch: next })
- }
- }),
- ),
- ),
- (unsubscribe) => Effect.sync(unsubscribe),
- )
- }
+ return value
+ }),
+ ),
+ )
return Service.of({
+ init: Effect.fn("Vcs.init")(function* () {
+ yield* InstanceState.get(state)
+ }),
branch: Effect.fn("Vcs.branch")(function* () {
- return currentBranch
+ return yield* InstanceState.use(state, (x) => x.current)
}),
})
}),
- ).pipe(Layer.fresh)
+ )
+
+ const runPromise = makeRunPromise(Service, layer)
+
+ export function init() {
+ return runPromise((svc) => svc.init())
+ }
+
+ export function branch() {
+ return runPromise((svc) => svc.branch())
+ }
}
diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts
deleted file mode 100644
index 5045e1edd..000000000
--- a/packages/opencode/src/provider/auth-service.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-import type { AuthOuathResult } from "@opencode-ai/plugin"
-import { NamedError } from "@opencode-ai/util/error"
-import * as Auth from "@/auth/effect"
-import { ProviderID } from "./schema"
-import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
-import z from "zod"
-
-export namespace ProviderAuth {
- export const Method = z
- .object({
- type: z.union([z.literal("oauth"), z.literal("api")]),
- label: z.string(),
- prompts: z
- .array(
- z.union([
- z.object({
- type: z.literal("text"),
- key: z.string(),
- message: z.string(),
- placeholder: z.string().optional(),
- when: z
- .object({
- key: z.string(),
- op: z.union([z.literal("eq"), z.literal("neq")]),
- value: z.string(),
- })
- .optional(),
- }),
- z.object({
- type: z.literal("select"),
- key: z.string(),
- message: z.string(),
- options: z.array(
- z.object({
- label: z.string(),
- value: z.string(),
- hint: z.string().optional(),
- }),
- ),
- when: z
- .object({
- key: z.string(),
- op: z.union([z.literal("eq"), z.literal("neq")]),
- value: z.string(),
- })
- .optional(),
- }),
- ]),
- )
- .optional(),
- })
- .meta({
- ref: "ProviderAuthMethod",
- })
- export type Method = z.infer<typeof Method>
-
- export const Authorization = z
- .object({
- url: z.string(),
- method: z.union([z.literal("auto"), z.literal("code")]),
- instructions: z.string(),
- })
- .meta({
- ref: "ProviderAuthAuthorization",
- })
- export type Authorization = z.infer<typeof Authorization>
-
- export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
-
- export const OauthCodeMissing = NamedError.create(
- "ProviderAuthOauthCodeMissing",
- z.object({ providerID: ProviderID.zod }),
- )
-
- export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
-
- export const ValidationFailed = NamedError.create(
- "ProviderAuthValidationFailed",
- z.object({
- field: z.string(),
- message: z.string(),
- }),
- )
-
- export type Error =
- | Auth.AuthError
- | InstanceType<typeof OauthMissing>
- | InstanceType<typeof OauthCodeMissing>
- | InstanceType<typeof OauthCallbackFailed>
- | InstanceType<typeof ValidationFailed>
-
- export interface Interface {
- readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
- readonly authorize: (input: {
- providerID: ProviderID
- method: number
- inputs?: Record<string, string>
- }) => Effect.Effect<Authorization | undefined, Error>
- readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const auth = yield* Auth.Auth.Service
- const hooks = yield* Effect.promise(async () => {
- const mod = await import("../plugin")
- const plugins = await mod.Plugin.list()
- return Record.fromEntries(
- Arr.filterMap(plugins, (x) =>
- x.auth?.provider !== undefined
- ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
- : Result.failVoid,
- ),
- )
- })
- const pending = new Map<ProviderID, AuthOuathResult>()
-
- const methods = Effect.fn("ProviderAuth.methods")(function* () {
- return Record.map(hooks, (item) =>
- item.methods.map(
- (method): Method => ({
- type: method.type,
- label: method.label,
- prompts: method.prompts?.map((prompt) => {
- if (prompt.type === "select") {
- return {
- type: "select" as const,
- key: prompt.key,
- message: prompt.message,
- options: prompt.options,
- when: prompt.when,
- }
- }
- return {
- type: "text" as const,
- key: prompt.key,
- message: prompt.message,
- placeholder: prompt.placeholder,
- when: prompt.when,
- }
- }),
- }),
- ),
- )
- })
-
- const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
- providerID: ProviderID
- method: number
- inputs?: Record<string, string>
- }) {
- const method = hooks[input.providerID].methods[input.method]
- if (method.type !== "oauth") return
-
- if (method.prompts && input.inputs) {
- for (const prompt of method.prompts) {
- if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
- const error = prompt.validate(input.inputs[prompt.key])
- if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
- }
- }
- }
-
- const result = yield* Effect.promise(() => method.authorize(input.inputs))
- pending.set(input.providerID, result)
- return {
- url: result.url,
- method: result.method,
- instructions: result.instructions,
- }
- })
-
- const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
- providerID: ProviderID
- method: number
- code?: string
- }) {
- const match = pending.get(input.providerID)
- if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
- if (match.method === "code" && !input.code) {
- return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
- }
-
- const result = yield* Effect.promise(() =>
- match.method === "code" ? match.callback(input.code!) : match.callback(),
- )
- if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
-
- if ("key" in result) {
- yield* auth.set(input.providerID, {
- type: "api",
- key: result.key,
- })
- }
-
- if ("refresh" in result) {
- yield* auth.set(input.providerID, {
- type: "oauth",
- access: result.access,
- refresh: result.refresh,
- expires: result.expires,
- ...(result.accountId ? { accountId: result.accountId } : {}),
- })
- }
- })
-
- return Service.of({ methods, authorize, callback })
- }),
- ).pipe(Layer.fresh)
-
- export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer))
-}
diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts
index 8ede977a5..2180d3063 100644
--- a/packages/opencode/src/provider/auth.ts
+++ b/packages/opencode/src/provider/auth.ts
@@ -1,48 +1,250 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { fn } from "@/util/fn"
+import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
+import { NamedError } from "@opencode-ai/util/error"
+import { Auth } from "@/auth"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
+import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
import z from "zod"
-import { ProviderAuth as S } from "./auth-service"
export namespace ProviderAuth {
- export const Method = S.Method
- export type Method = S.Method
+ export const Method = z
+ .object({
+ type: z.union([z.literal("oauth"), z.literal("api")]),
+ label: z.string(),
+ prompts: z
+ .array(
+ z.union([
+ z.object({
+ type: z.literal("text"),
+ key: z.string(),
+ message: z.string(),
+ placeholder: z.string().optional(),
+ when: z
+ .object({
+ key: z.string(),
+ op: z.union([z.literal("eq"), z.literal("neq")]),
+ value: z.string(),
+ })
+ .optional(),
+ }),
+ z.object({
+ type: z.literal("select"),
+ key: z.string(),
+ message: z.string(),
+ options: z.array(
+ z.object({
+ label: z.string(),
+ value: z.string(),
+ hint: z.string().optional(),
+ }),
+ ),
+ when: z
+ .object({
+ key: z.string(),
+ op: z.union([z.literal("eq"), z.literal("neq")]),
+ value: z.string(),
+ })
+ .optional(),
+ }),
+ ]),
+ )
+ .optional(),
+ })
+ .meta({
+ ref: "ProviderAuthMethod",
+ })
+ export type Method = z.infer<typeof Method>
- export const Authorization = S.Authorization
- export type Authorization = S.Authorization
+ export const Authorization = z
+ .object({
+ url: z.string(),
+ method: z.union([z.literal("auto"), z.literal("code")]),
+ instructions: z.string(),
+ })
+ .meta({
+ ref: "ProviderAuthAuthorization",
+ })
+ export type Authorization = z.infer<typeof Authorization>
- export const OauthMissing = S.OauthMissing
- export const OauthCodeMissing = S.OauthCodeMissing
- export const OauthCallbackFailed = S.OauthCallbackFailed
- export const ValidationFailed = S.ValidationFailed
- export type Error = S.Error
+ export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
- export type Interface = S.Interface
-
- export const Service = S.Service
- export const layer = S.layer
- export const defaultLayer = S.defaultLayer
+ export const OauthCodeMissing = NamedError.create(
+ "ProviderAuthOauthCodeMissing",
+ z.object({ providerID: ProviderID.zod }),
+ )
- export async function methods() {
- return runPromiseInstance(S.Service.use((svc) => svc.methods()))
- }
+ export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
- export const authorize = fn(
+ export const ValidationFailed = NamedError.create(
+ "ProviderAuthValidationFailed",
z.object({
- providerID: ProviderID.zod,
- method: z.number(),
- inputs: z.record(z.string(), z.string()).optional(),
+ field: z.string(),
+ message: z.string(),
}),
- async (input): Promise<Authorization | undefined> =>
- runPromiseInstance(S.Service.use((svc) => svc.authorize(input))),
)
- export const callback = fn(
- z.object({
- providerID: ProviderID.zod,
- method: z.number(),
- code: z.string().optional(),
+ export type Error =
+ | Auth.AuthError
+ | InstanceType<typeof OauthMissing>
+ | InstanceType<typeof OauthCodeMissing>
+ | InstanceType<typeof OauthCallbackFailed>
+ | InstanceType<typeof ValidationFailed>
+
+ type Hook = NonNullable<Hooks["auth"]>
+
+ export interface Interface {
+ readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
+ readonly authorize: (input: {
+ providerID: ProviderID
+ method: number
+ inputs?: Record<string, string>
+ }) => Effect.Effect<Authorization | undefined, Error>
+ readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
+ }
+
+ interface State {
+ hooks: Record<ProviderID, Hook>
+ pending: Map<ProviderID, AuthOuathResult>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const auth = yield* Auth.Service
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("ProviderAuth.state")(() =>
+ Effect.promise(async () => {
+ const plugins = await Plugin.list()
+ return {
+ hooks: Record.fromEntries(
+ Arr.filterMap(plugins, (x) =>
+ x.auth?.provider !== undefined
+ ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
+ : Result.failVoid,
+ ),
+ ),
+ pending: new Map<ProviderID, AuthOuathResult>(),
+ }
+ })),
+ )
+
+ const methods = Effect.fn("ProviderAuth.methods")(function* () {
+ const hooks = (yield* InstanceState.get(state)).hooks
+ return Record.map(hooks, (item) =>
+ item.methods.map(
+ (method): Method => ({
+ type: method.type,
+ label: method.label,
+ prompts: method.prompts?.map((prompt) => {
+ if (prompt.type === "select") {
+ return {
+ type: "select" as const,
+ key: prompt.key,
+ message: prompt.message,
+ options: prompt.options,
+ when: prompt.when,
+ }
+ }
+ return {
+ type: "text" as const,
+ key: prompt.key,
+ message: prompt.message,
+ placeholder: prompt.placeholder,
+ when: prompt.when,
+ }
+ }),
+ }),
+ ),
+ )
+ })
+
+ const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
+ providerID: ProviderID
+ method: number
+ inputs?: Record<string, string>
+ }) {
+ const { hooks, pending } = yield* InstanceState.get(state)
+ const method = hooks[input.providerID].methods[input.method]
+ if (method.type !== "oauth") return
+
+ if (method.prompts && input.inputs) {
+ for (const prompt of method.prompts) {
+ if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
+ const error = prompt.validate(input.inputs[prompt.key])
+ if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
+ }
+ }
+ }
+
+ const result = yield* Effect.promise(() => method.authorize(input.inputs))
+ pending.set(input.providerID, result)
+ return {
+ url: result.url,
+ method: result.method,
+ instructions: result.instructions,
+ }
+ })
+
+ const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
+ providerID: ProviderID
+ method: number
+ code?: string
+ }) {
+ const pending = (yield* InstanceState.get(state)).pending
+ const match = pending.get(input.providerID)
+ if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
+ if (match.method === "code" && !input.code) {
+ return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
+ }
+
+ const result = yield* Effect.promise(() =>
+ match.method === "code" ? match.callback(input.code!) : match.callback(),
+ )
+ if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
+
+ if ("key" in result) {
+ yield* auth.set(input.providerID, {
+ type: "api",
+ key: result.key,
+ })
+ }
+
+ if ("refresh" in result) {
+ yield* auth.set(input.providerID, {
+ type: "oauth",
+ access: result.access,
+ refresh: result.refresh,
+ expires: result.expires,
+ ...(result.accountId ? { accountId: result.accountId } : {}),
+ })
+ }
+ })
+
+ return Service.of({ methods, authorize, callback })
}),
- async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))),
)
+
+ export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
+
+ const runPromise = makeRunPromise(Service, defaultLayer)
+
+ export async function methods() {
+ return runPromise((svc) => svc.methods())
+ }
+
+ export async function authorize(input: {
+ providerID: ProviderID
+ method: number
+ inputs?: Record<string, string>
+ }): Promise<Authorization | undefined> {
+ return runPromise((svc) => svc.authorize(input))
+ }
+
+ export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
+ return runPromise((svc) => svc.callback(input))
+ }
}
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
index de0095190..a0d62d94b 100644
--- a/packages/opencode/src/question/index.ts
+++ b/packages/opencode/src/question/index.ts
@@ -1,49 +1,221 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import type { MessageID, SessionID } from "@/session/schema"
-import type { QuestionID } from "./schema"
-import { Question as S } from "./service"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { SessionID, MessageID } from "@/session/schema"
+import { Log } from "@/util/log"
+import z from "zod"
+import { QuestionID } from "./schema"
export namespace Question {
- export const Option = S.Option
- export type Option = S.Option
+ const log = Log.create({ service: "question" })
- export const Info = S.Info
- export type Info = S.Info
+ // Schemas
- export const Request = S.Request
- export type Request = S.Request
+ 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 Answer = S.Answer
- export type Answer = S.Answer
+ 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 Reply = S.Reply
- export type Reply = S.Reply
+ 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 Event = S.Event
- export const RejectedError = S.RejectedError
+ export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
+ export type Answer = z.infer<typeof Answer>
- export type Interface = S.Interface
+ 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 Service = S.Service
- export const layer = S.layer
+ 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"
+ }
+ }
+
+ interface PendingEntry {
+ info: Request
+ deferred: Deferred.Deferred<Answer[], RejectedError>
+ }
+
+ interface State {
+ pending: Map<QuestionID, PendingEntry>
+ }
+
+ // Service
+
+ 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 state = yield* InstanceState.make<State>(
+ Effect.fn("Question.state")(function* () {
+ const state = {
+ pending: new Map<QuestionID, PendingEntry>(),
+ }
+
+ yield* Effect.addFinalizer(() =>
+ Effect.gen(function* () {
+ for (const item of state.pending.values()) {
+ yield* Deferred.fail(item.deferred, new RejectedError())
+ }
+ state.pending.clear()
+ }),
+ )
+
+ return state
+ }),
+ )
+
+ const ask = Effect.fn("Question.ask")(function* (input: {
+ sessionID: SessionID
+ questions: Info[]
+ tool?: { messageID: MessageID; callID: string }
+ }) {
+ const pending = (yield* InstanceState.get(state)).pending
+ 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 pending = (yield* InstanceState.get(state)).pending
+ 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 pending = (yield* InstanceState.get(state)).pending
+ 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* () {
+ const pending = (yield* InstanceState.get(state)).pending
+ return Array.from(pending.values(), (x) => x.info)
+ })
+
+ return Service.of({ ask, reply, reject, list })
+ }),
+ )
+
+ const runPromise = makeRunPromise(Service, layer)
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
- return runPromiseInstance(S.Service.use((s) => s.ask(input)))
+ return runPromise((s) => s.ask(input))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
- return runPromiseInstance(S.Service.use((s) => s.reply(input)))
+ return runPromise((s) => s.reply(input))
}
export async function reject(requestID: QuestionID) {
- return runPromiseInstance(S.Service.use((s) => s.reject(requestID)))
+ return runPromise((s) => s.reject(requestID))
}
export async function list() {
- return runPromiseInstance(S.Service.use((s) => s.list()))
+ return runPromise((s) => s.list())
}
}
diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts
deleted file mode 100644
index a23703e97..000000000
--- a/packages/opencode/src/question/service.ts
+++ /dev/null
@@ -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" })
-
-export namespace Question {
- // Schemas
-
- 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"
- }
- }
-
- interface PendingEntry {
- info: Request
- deferred: Deferred.Deferred<Answer[], RejectedError>
- }
-
- // Service
-
- 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, PendingEntry>()
-
- 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 })
- }),
- ).pipe(Layer.fresh)
-}
diff --git a/packages/opencode/src/server/routes/permission.ts b/packages/opencode/src/server/routes/permission.ts
index cc6c26d43..aae9a9c3a 100644
--- a/packages/opencode/src/server/routes/permission.ts
+++ b/packages/opencode/src/server/routes/permission.ts
@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { PermissionNext } from "@/permission"
+import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -32,11 +32,11 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod,
}),
),
- validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
+ validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
- await PermissionNext.reply({
+ await Permission.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
@@ -55,14 +55,14 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions",
content: {
"application/json": {
- schema: resolver(PermissionNext.Request.array()),
+ schema: resolver(Permission.Request.array()),
},
},
},
},
}),
async (c) => {
- const permissions = await PermissionNext.list()
+ const permissions = await Permission.list()
return c.json(permissions)
},
),
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 564bb496b..e399636ad 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -12,9 +12,9 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
-import { PermissionNext } from "@/permission"
+import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
@@ -1010,10 +1010,10 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod,
}),
),
- validator("json", z.object({ response: PermissionNext.Reply })),
+ validator("json", z.object({ response: Permission.Reply })),
async (c) => {
const params = c.req.valid("param")
- PermissionNext.reply({
+ Permission.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index a68becb1f..7ead4df8a 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -12,9 +12,8 @@ import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
-import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent"
-import { Skill } from "../skill/skill"
+import { Skill } from "../skill"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Command } from "../command"
@@ -152,7 +151,7 @@ export namespace Server {
providerID: ProviderID.zod,
}),
),
- validator("json", Auth.Info),
+ validator("json", Auth.Info.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const info = c.req.valid("json")
@@ -331,7 +330,7 @@ export namespace Server {
},
}),
async (c) => {
- const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
+ const branch = await Vcs.branch()
return c.json({
branch,
})
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index bbb7c97fd..f2d436ff1 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -20,7 +20,7 @@ import { Instance } from "../project/instance"
import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
import { WorkspaceContext } from "../control-plane/workspace-context"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
@@ -148,7 +148,7 @@ export namespace Session {
compacting: z.number().optional(),
archived: z.number().optional(),
}),
- permission: PermissionNext.Ruleset.optional(),
+ permission: Permission.Ruleset.optional(),
revert: z
.object({
messageID: MessageID.zod,
@@ -300,7 +300,7 @@ export namespace Session {
parentID?: SessionID
workspaceID?: WorkspaceID
directory: string
- permission?: PermissionNext.Ruleset
+ permission?: Permission.Ruleset
}) {
const result: Info = {
id: SessionID.descending(input.id),
@@ -423,7 +423,7 @@ export namespace Session {
export const setPermission = fn(
z.object({
sessionID: SessionID.zod,
- permission: PermissionNext.Ruleset,
+ permission: Permission.Ruleset,
}),
async (input) => {
return Database.use((db) => {
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index b28a595e1..a8009c49d 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -21,7 +21,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
import { Auth } from "@/auth"
export namespace LLM {
@@ -33,7 +33,7 @@ export namespace LLM {
sessionID: string
model: Provider.Model
agent: Agent.Info
- permission?: PermissionNext.Ruleset
+ permission?: Permission.Ruleset
system: string[]
abort: AbortSignal
messages: ModelMessage[]
@@ -286,9 +286,9 @@ export namespace LLM {
}
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
- const disabled = PermissionNext.disabled(
+ const disabled = Permission.disabled(
Object.keys(input.tools),
- PermissionNext.merge(input.agent.permission, input.permission ?? []),
+ Permission.merge(input.agent.permission, input.permission ?? []),
)
for (const tool of Object.keys(input.tools)) {
if (input.user.tools?.[tool] === false || disabled.has(tool)) {
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 3e1816e68..f1335f6f2 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -4,7 +4,7 @@ import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { LSP } from "../lsp"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 2fe3310ca..c3a572f5b 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -12,8 +12,8 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
-import { PermissionNext } from "@/permission"
-import { Question } from "@/question/service"
+import { Permission } from "@/permission"
+import { Question } from "@/question"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"
@@ -163,7 +163,7 @@ export namespace SessionProcessor {
)
) {
const agent = await Agent.get(input.assistantMessage.agent)
- await PermissionNext.ask({
+ await Permission.ask({
permission: "doom_loop",
patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID,
@@ -219,7 +219,7 @@ export namespace SessionProcessor {
})
if (
- value.error instanceof PermissionNext.RejectedError ||
+ value.error instanceof Permission.RejectedError ||
value.error instanceof Question.RejectedError
) {
blocked = shouldBreak
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index bac958ec1..5625c571c 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -41,7 +41,7 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
-import { PermissionNext } from "@/permission"
+import { Permission } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
@@ -168,7 +168,7 @@ export namespace SessionPrompt {
// this is backwards compatibility for allowing `tools` to be specified when
// prompting
- const permissions: PermissionNext.Ruleset = []
+ const permissions: Permission.Ruleset = []
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({
permission: tool,
@@ -437,10 +437,10 @@ export namespace SessionPrompt {
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
},
async ask(req) {
- await PermissionNext.ask({
+ await Permission.ask({
...req,
sessionID: sessionID,
- ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
+ ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
})
},
}
@@ -781,11 +781,11 @@ export namespace SessionPrompt {
}
},
async ask(req) {
- await PermissionNext.ask({
+ await Permission.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
- ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
+ ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
})
},
})
@@ -1271,7 +1271,7 @@ export namespace SessionPrompt {
if (part.type === "agent") {
// Check if this agent would be denied by task permission
- const perm = PermissionNext.evaluate("task", part.name, agent.permission)
+ const perm = Permission.evaluate("task", part.name, agent.permission)
const hint = perm.action === "deny" ? " . Invoked by user; guaranteed to exist." : ""
return [
{
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
index f73e16804..189a59687 100644
--- a/packages/opencode/src/session/session.sql.ts
+++ b/packages/opencode/src/session/session.sql.ts
@@ -1,8 +1,8 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
-import type { Snapshot } from "../snapshot/service"
-import type { Permission as PermissionNext } from "../permission/service"
+import type { Snapshot } from "../snapshot"
+import type { Permission } from "../permission"
import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema"
@@ -31,7 +31,7 @@ export const SessionTable = sqliteTable(
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
- permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
+ permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
...Timestamps,
time_compacting: integer(),
time_archived: integer(),
@@ -99,5 +99,5 @@ export const PermissionTable = sqliteTable("permission", {
.primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps,
- data: text({ mode: "json" }).notNull().$type<PermissionNext.Ruleset>(),
+ data: text({ mode: "json" }).notNull().$type<Permission.Ruleset>(),
})
diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts
index ead715cfb..ca324652d 100644
--- a/packages/opencode/src/session/system.ts
+++ b/packages/opencode/src/session/system.ts
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
import { Skill } from "@/skill"
export namespace SystemPrompt {
@@ -53,7 +53,7 @@ export namespace SystemPrompt {
}
export async function skills(agent: Agent.Info) {
- if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
+ if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = await Skill.available(agent)
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index e911656c9..e331e8fc6 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -45,7 +45,7 @@ export namespace ShareNext {
}> {
const headers: Record<string, string> = {}
- const active = Account.active()
+ const active = await Account.active()
if (!active?.active_org_id) {
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return { headers, api: legacyApi, baseUrl }
diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts
index 67bef3bd3..b770ab83c 100644
--- a/packages/opencode/src/skill/index.ts
+++ b/packages/opencode/src/skill/index.ts
@@ -1 +1,260 @@
-export * from "./skill"
+import os from "os"
+import path from "path"
+import { pathToFileURL } from "url"
+import z from "zod"
+import { Effect, Layer, ServiceMap } from "effect"
+import { NamedError } from "@opencode-ai/util/error"
+import type { Agent } from "@/agent/agent"
+import { Bus } from "@/bus"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Permission } from "@/permission"
+import { Filesystem } from "@/util/filesystem"
+import { Config } from "../config/config"
+import { ConfigMarkdown } from "../config/markdown"
+import { Glob } from "../util/glob"
+import { Log } from "../util/log"
+import { Discovery } from "./discovery"
+
+export namespace Skill {
+ const log = Log.create({ service: "skill" })
+ const EXTERNAL_DIRS = [".claude", ".agents"]
+ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
+ const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
+ const SKILL_PATTERN = "**/SKILL.md"
+
+ export const Info = z.object({
+ name: z.string(),
+ description: z.string(),
+ location: z.string(),
+ content: z.string(),
+ })
+ export type Info = z.infer<typeof Info>
+
+ export const InvalidError = NamedError.create(
+ "SkillInvalidError",
+ z.object({
+ path: z.string(),
+ message: z.string().optional(),
+ issues: z.custom<z.core.$ZodIssue[]>().optional(),
+ }),
+ )
+
+ export const NameMismatchError = NamedError.create(
+ "SkillNameMismatchError",
+ z.object({
+ path: z.string(),
+ expected: z.string(),
+ actual: z.string(),
+ }),
+ )
+
+ type State = {
+ skills: Record<string, Info>
+ dirs: Set<string>
+ task?: Promise<void>
+ }
+
+ type Cache = State & {
+ ensure: () => Promise<void>
+ }
+
+ export interface Interface {
+ readonly get: (name: string) => Effect.Effect<Info | undefined>
+ readonly all: () => Effect.Effect<Info[]>
+ readonly dirs: () => Effect.Effect<string[]>
+ readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
+ }
+
+ const add = async (state: State, match: string) => {
+ const md = await ConfigMarkdown.parse(match).catch(async (err) => {
+ const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+ ? err.data.message
+ : `Failed to parse skill ${match}`
+ const { Session } = await import("@/session")
+ Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+ log.error("failed to load skill", { skill: match, err })
+ return undefined
+ })
+
+ if (!md) return
+
+ const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+ if (!parsed.success) return
+
+ if (state.skills[parsed.data.name]) {
+ log.warn("duplicate skill name", {
+ name: parsed.data.name,
+ existing: state.skills[parsed.data.name].location,
+ duplicate: match,
+ })
+ }
+
+ state.dirs.add(path.dirname(match))
+ state.skills[parsed.data.name] = {
+ name: parsed.data.name,
+ description: parsed.data.description,
+ location: match,
+ content: md.content,
+ }
+ }
+
+ const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
+ return Glob.scan(pattern, {
+ cwd: root,
+ absolute: true,
+ include: "file",
+ symlink: true,
+ dot: opts?.dot,
+ })
+ .then((matches) => Promise.all(matches.map((match) => add(state, match))))
+ .catch((error) => {
+ if (!opts?.scope) throw error
+ log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
+ })
+ }
+
+ // TODO: Migrate to Effect
+ const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
+ const state: State = {
+ skills: {},
+ dirs: new Set<string>(),
+ }
+
+ const load = async () => {
+ if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+ for (const dir of EXTERNAL_DIRS) {
+ const root = path.join(Global.Path.home, dir)
+ if (!(await Filesystem.isDir(root))) continue
+ await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
+ }
+
+ for await (const root of Filesystem.up({
+ targets: EXTERNAL_DIRS,
+ start: directory,
+ stop: worktree,
+ })) {
+ await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
+ }
+ }
+
+ for (const dir of await Config.directories()) {
+ await scan(state, dir, OPENCODE_SKILL_PATTERN)
+ }
+
+ const cfg = await Config.get()
+ for (const item of cfg.skills?.paths ?? []) {
+ const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
+ const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
+ if (!(await Filesystem.isDir(dir))) {
+ log.warn("skill path not found", { path: dir })
+ continue
+ }
+
+ await scan(state, dir, SKILL_PATTERN)
+ }
+
+ for (const url of cfg.skills?.urls ?? []) {
+ for (const dir of await Effect.runPromise(discovery.pull(url))) {
+ state.dirs.add(dir)
+ await scan(state, dir, SKILL_PATTERN)
+ }
+ }
+
+ log.info("init", { count: Object.keys(state.skills).length })
+ }
+
+ const ensure = () => {
+ if (state.task) return state.task
+ state.task = load().catch((err) => {
+ state.task = undefined
+ throw err
+ })
+ return state.task
+ }
+
+ return { ...state, ensure }
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
+
+ export const layer: Layer.Layer<Service, never, Discovery.Service> = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const discovery = yield* Discovery.Service
+ const state = yield* InstanceState.make(Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))))
+
+ const ensure = Effect.fn("Skill.ensure")(function* () {
+ const cache = yield* InstanceState.get(state)
+ yield* Effect.promise(() => cache.ensure())
+ return cache
+ })
+
+ const get = Effect.fn("Skill.get")(function* (name: string) {
+ const cache = yield* ensure()
+ return cache.skills[name]
+ })
+
+ const all = Effect.fn("Skill.all")(function* () {
+ const cache = yield* ensure()
+ return Object.values(cache.skills)
+ })
+
+ const dirs = Effect.fn("Skill.dirs")(function* () {
+ const cache = yield* ensure()
+ return Array.from(cache.dirs)
+ })
+
+ const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
+ const cache = yield* ensure()
+ const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+ if (!agent) return list
+ return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
+ })
+
+ return Service.of({ get, all, dirs, available })
+ }),
+ )
+
+ export const defaultLayer: Layer.Layer<Service> = layer.pipe(Layer.provide(Discovery.defaultLayer))
+
+ export function fmt(list: Info[], opts: { verbose: boolean }) {
+ if (list.length === 0) return "No skills are currently available."
+
+ if (opts.verbose) {
+ return [
+ "<available_skills>",
+ ...list.flatMap((skill) => [
+ " <skill>",
+ ` <name>${skill.name}</name>`,
+ ` <description>${skill.description}</description>`,
+ ` <location>${pathToFileURL(skill.location).href}</location>`,
+ " </skill>",
+ ]),
+ "</available_skills>",
+ ].join("\n")
+ }
+
+ return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
+ }
+
+ const runPromise = makeRunPromise(Service, defaultLayer)
+
+ export async function get(name: string) {
+ return runPromise((skill) => skill.get(name))
+ }
+
+ export async function all() {
+ return runPromise((skill) => skill.all())
+ }
+
+ export async function dirs() {
+ return runPromise((skill) => skill.dirs())
+ }
+
+ export async function available(agent?: Agent.Info) {
+ return runPromise((skill) => skill.available(agent))
+ }
+}
diff --git a/packages/opencode/src/skill/service.ts b/packages/opencode/src/skill/service.ts
deleted file mode 100644
index 434a51bad..000000000
--- a/packages/opencode/src/skill/service.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-import os from "os"
-import path from "path"
-import { pathToFileURL } from "url"
-import z from "zod"
-import { Effect, Layer, ServiceMap } from "effect"
-import { NamedError } from "@opencode-ai/util/error"
-import type { Agent } from "@/agent/agent"
-import { Bus } from "@/bus"
-import { InstanceContext } from "@/effect/instance-context"
-import { Flag } from "@/flag/flag"
-import { Global } from "@/global"
-import { Permission } from "@/permission/service"
-import { Filesystem } from "@/util/filesystem"
-import { Config } from "../config/config"
-import { ConfigMarkdown } from "../config/markdown"
-import { Glob } from "../util/glob"
-import { Log } from "../util/log"
-import { Discovery } from "./discovery"
-
-export namespace Skill {
- const log = Log.create({ service: "skill" })
- const EXTERNAL_DIRS = [".claude", ".agents"]
- const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
- const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
- const SKILL_PATTERN = "**/SKILL.md"
-
- export const Info = z.object({
- name: z.string(),
- description: z.string(),
- location: z.string(),
- content: z.string(),
- })
- export type Info = z.infer<typeof Info>
-
- export const InvalidError = NamedError.create(
- "SkillInvalidError",
- z.object({
- path: z.string(),
- message: z.string().optional(),
- issues: z.custom<z.core.$ZodIssue[]>().optional(),
- }),
- )
-
- export const NameMismatchError = NamedError.create(
- "SkillNameMismatchError",
- z.object({
- path: z.string(),
- expected: z.string(),
- actual: z.string(),
- }),
- )
-
- type State = {
- skills: Record<string, Info>
- dirs: Set<string>
- task?: Promise<void>
- }
-
- type Cache = State & {
- ensure: () => Promise<void>
- }
-
- export interface Interface {
- readonly get: (name: string) => Effect.Effect<Info | undefined>
- readonly all: () => Effect.Effect<Info[]>
- readonly dirs: () => Effect.Effect<string[]>
- readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
- }
-
- const add = async (state: State, match: string) => {
- const md = await ConfigMarkdown.parse(match).catch(async (err) => {
- const message = ConfigMarkdown.FrontmatterError.isInstance(err)
- ? err.data.message
- : `Failed to parse skill ${match}`
- const { Session } = await import("@/session")
- Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
- log.error("failed to load skill", { skill: match, err })
- return undefined
- })
-
- if (!md) return
-
- const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
- if (!parsed.success) return
-
- if (state.skills[parsed.data.name]) {
- log.warn("duplicate skill name", {
- name: parsed.data.name,
- existing: state.skills[parsed.data.name].location,
- duplicate: match,
- })
- }
-
- state.dirs.add(path.dirname(match))
- state.skills[parsed.data.name] = {
- name: parsed.data.name,
- description: parsed.data.description,
- location: match,
- content: md.content,
- }
- }
-
- const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
- return Glob.scan(pattern, {
- cwd: root,
- absolute: true,
- include: "file",
- symlink: true,
- dot: opts?.dot,
- })
- .then((matches) => Promise.all(matches.map((match) => add(state, match))))
- .catch((error) => {
- if (!opts?.scope) throw error
- log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
- })
- }
-
- // TODO: Migrate to Effect
- const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
- const state: State = {
- skills: {},
- dirs: new Set<string>(),
- }
-
- const load = async () => {
- if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
- for (const dir of EXTERNAL_DIRS) {
- const root = path.join(Global.Path.home, dir)
- if (!(await Filesystem.isDir(root))) continue
- await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
- }
-
- for await (const root of Filesystem.up({
- targets: EXTERNAL_DIRS,
- start: instance.directory,
- stop: instance.project.worktree,
- })) {
- await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
- }
- }
-
- for (const dir of await Config.directories()) {
- await scan(state, dir, OPENCODE_SKILL_PATTERN)
- }
-
- const cfg = await Config.get()
- for (const item of cfg.skills?.paths ?? []) {
- const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
- const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
- if (!(await Filesystem.isDir(dir))) {
- log.warn("skill path not found", { path: dir })
- continue
- }
-
- await scan(state, dir, SKILL_PATTERN)
- }
-
- for (const url of cfg.skills?.urls ?? []) {
- for (const dir of await Effect.runPromise(discovery.pull(url))) {
- state.dirs.add(dir)
- await scan(state, dir, SKILL_PATTERN)
- }
- }
-
- log.info("init", { count: Object.keys(state.skills).length })
- }
-
- const ensure = () => {
- if (state.task) return state.task
- state.task = load().catch((err) => {
- state.task = undefined
- throw err
- })
- return state.task
- }
-
- return { ...state, ensure }
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
-
- export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
- Service,
- Effect.gen(function* () {
- const instance = yield* InstanceContext
- const discovery = yield* Discovery.Service
- const state = create(instance, discovery)
-
- const get = Effect.fn("Skill.get")(function* (name: string) {
- yield* Effect.promise(() => state.ensure())
- return state.skills[name]
- })
-
- const all = Effect.fn("Skill.all")(function* () {
- yield* Effect.promise(() => state.ensure())
- return Object.values(state.skills)
- })
-
- const dirs = Effect.fn("Skill.dirs")(function* () {
- yield* Effect.promise(() => state.ensure())
- return Array.from(state.dirs)
- })
-
- const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
- yield* Effect.promise(() => state.ensure())
- const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
- if (!agent) return list
- return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
- })
-
- return Service.of({ get, all, dirs, available })
- }),
- ).pipe(Layer.fresh)
-
- export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
- Layer.provide(Discovery.defaultLayer),
- )
-
- export function fmt(list: Info[], opts: { verbose: boolean }) {
- if (list.length === 0) return "No skills are currently available."
-
- if (opts.verbose) {
- return [
- "<available_skills>",
- ...list.flatMap((skill) => [
- " <skill>",
- ` <name>${skill.name}</name>`,
- ` <description>${skill.description}</description>`,
- ` <location>${pathToFileURL(skill.location).href}</location>`,
- " </skill>",
- ]),
- "</available_skills>",
- ].join("\n")
- }
-
- return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
- }
-}
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
deleted file mode 100644
index ed3e0a4b7..000000000
--- a/packages/opencode/src/skill/skill.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import type { Agent } from "@/agent/agent"
-import { Skill as S } from "./service"
-
-export namespace Skill {
- export const Info = S.Info
- export type Info = S.Info
-
- export const InvalidError = S.InvalidError
- export const NameMismatchError = S.NameMismatchError
-
- export type Interface = S.Interface
-
- export const Service = S.Service
- export const layer = S.layer
- export const defaultLayer = S.defaultLayer
-
- export const fmt = S.fmt
-
- export async function get(name: string) {
- return runPromiseInstance(S.Service.use((skill) => skill.get(name)))
- }
-
- export async function all() {
- return runPromiseInstance(S.Service.use((skill) => skill.all()))
- }
-
- export async function dirs() {
- return runPromiseInstance(S.Service.use((skill) => skill.dirs()))
- }
-
- export async function available(agent?: Agent.Info) {
- return runPromiseInstance(S.Service.use((skill) => skill.available(agent)))
- }
-}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index 4f845ca2d..5f8c5aeff 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -1,44 +1,396 @@
-import { runPromiseInstance } from "@/effect/runtime"
-import { Snapshot as S } from "./service"
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import path from "path"
+import z from "zod"
+import { InstanceState } from "@/effect/instance-state"
+import { makeRunPromise } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
+import { Config } from "../config/config"
+import { Global } from "../global"
+import { Log } from "../util/log"
export namespace Snapshot {
- export const Patch = S.Patch
- export type Patch = S.Patch
+ export const Patch = z.object({
+ hash: z.string(),
+ files: z.string().array(),
+ })
+ export type Patch = z.infer<typeof Patch>
- export const FileDiff = S.FileDiff
- export type FileDiff = S.FileDiff
+ export const FileDiff = z
+ .object({
+ file: z.string(),
+ before: z.string(),
+ after: z.string(),
+ additions: z.number(),
+ deletions: z.number(),
+ status: z.enum(["added", "deleted", "modified"]).optional(),
+ })
+ .meta({
+ ref: "FileDiff",
+ })
+ export type FileDiff = z.infer<typeof FileDiff>
- export type Interface = S.Interface
+ const log = Log.create({ service: "snapshot" })
+ const prune = "7.days"
+ const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
+ const cfg = ["-c", "core.autocrlf=false", ...core]
+ const quote = [...cfg, "-c", "core.quotepath=false"]
- export const Service = S.Service
- export const layer = S.layer
- export const defaultLayer = S.defaultLayer
+ interface GitResult {
+ readonly code: ChildProcessSpawner.ExitCode
+ readonly text: string
+ readonly stderr: string
+ }
+
+ type State = Omit<Interface, "init">
+
+ export interface Interface {
+ readonly init: () => Effect.Effect<void>
+ readonly cleanup: () => Effect.Effect<void>
+ readonly track: () => Effect.Effect<string | undefined>
+ readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
+ readonly restore: (snapshot: string) => Effect.Effect<void>
+ readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
+ readonly diff: (hash: string) => Effect.Effect<string>
+ readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
+
+ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Snapshot.state")(function* (ctx) {
+ const state = {
+ directory: ctx.directory,
+ worktree: ctx.worktree,
+ gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
+ vcs: ctx.project.vcs,
+ }
+
+ const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
+
+ const git = Effect.fnUntraced(
+ function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+ const proc = ChildProcess.make("git", cmd, {
+ cwd: opts?.cwd,
+ env: opts?.env,
+ extendEnv: true,
+ })
+ const handle = yield* spawner.spawn(proc)
+ const [text, stderr] = yield* Effect.all(
+ [
+ Stream.mkString(Stream.decodeText(handle.stdout)),
+ Stream.mkString(Stream.decodeText(handle.stderr)),
+ ],
+ { concurrency: 2 },
+ )
+ const code = yield* handle.exitCode
+ return { code, text, stderr } satisfies GitResult
+ },
+ Effect.scoped,
+ Effect.catch((err) =>
+ Effect.succeed({
+ code: ChildProcessSpawner.ExitCode(1),
+ text: "",
+ stderr: String(err),
+ }),
+ ),
+ )
+
+ const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+ const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
+ const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
+
+ const enabled = Effect.fnUntraced(function* () {
+ if (state.vcs !== "git") return false
+ return (yield* Effect.promise(() => Config.get())).snapshot !== false
+ })
+
+ const excludes = Effect.fnUntraced(function* () {
+ const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+ cwd: state.worktree,
+ })
+ const file = result.text.trim()
+ if (!file) return
+ if (!(yield* exists(file))) return
+ return file
+ })
+
+ const sync = Effect.fnUntraced(function* () {
+ const file = yield* excludes()
+ const target = path.join(state.gitdir, "info", "exclude")
+ yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
+ if (!file) {
+ yield* fs.writeFileString(target, "").pipe(Effect.orDie)
+ return
+ }
+ yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
+ })
+
+ const add = Effect.fnUntraced(function* () {
+ yield* sync()
+ yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
+ })
+
+ const cleanup = Effect.fnUntraced(function* () {
+ if (!(yield* enabled())) return
+ if (!(yield* exists(state.gitdir))) return
+ const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+ if (result.code !== 0) {
+ log.warn("cleanup failed", {
+ exitCode: result.code,
+ stderr: result.stderr,
+ })
+ return
+ }
+ log.info("cleanup", { prune })
+ })
+
+ const track = Effect.fnUntraced(function* () {
+ if (!(yield* enabled())) return
+ const existed = yield* exists(state.gitdir)
+ yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
+ if (!existed) {
+ yield* git(["init"], {
+ env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
+ })
+ yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
+ yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
+ yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
+ yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
+ log.info("initialized")
+ }
+ yield* add()
+ const result = yield* git(args(["write-tree"]), { cwd: state.directory })
+ const hash = result.text.trim()
+ log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
+ return hash
+ })
+
+ const patch = Effect.fnUntraced(function* (hash: string) {
+ yield* add()
+ const result = yield* git(
+ [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
+ {
+ cwd: state.directory,
+ },
+ )
+ if (result.code !== 0) {
+ log.warn("failed to get diff", { hash, exitCode: result.code })
+ return { hash, files: [] }
+ }
+ return {
+ hash,
+ files: result.text
+ .trim()
+ .split("\n")
+ .map((x) => x.trim())
+ .filter(Boolean)
+ .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+ }
+ })
+
+ const restore = Effect.fnUntraced(function* (snapshot: string) {
+ log.info("restore", { commit: snapshot })
+ const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
+ if (result.code === 0) {
+ const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
+ if (checkout.code === 0) return
+ log.error("failed to restore snapshot", {
+ snapshot,
+ exitCode: checkout.code,
+ stderr: checkout.stderr,
+ })
+ return
+ }
+ log.error("failed to restore snapshot", {
+ snapshot,
+ exitCode: result.code,
+ stderr: result.stderr,
+ })
+ })
+
+ const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
+ const seen = new Set<string>()
+ for (const item of patches) {
+ for (const file of item.files) {
+ if (seen.has(file)) continue
+ seen.add(file)
+ log.info("reverting", { file, hash: item.hash })
+ const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
+ cwd: state.worktree,
+ })
+ if (result.code !== 0) {
+ const rel = path.relative(state.worktree, file)
+ const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
+ cwd: state.worktree,
+ })
+ if (tree.code === 0 && tree.text.trim()) {
+ log.info("file existed in snapshot but checkout failed, keeping", { file })
+ } else {
+ log.info("file did not exist in snapshot, deleting", { file })
+ yield* remove(file)
+ }
+ }
+ }
+ }
+ })
+
+ const diff = Effect.fnUntraced(function* (hash: string) {
+ yield* add()
+ const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
+ cwd: state.worktree,
+ })
+ if (result.code !== 0) {
+ log.warn("failed to get diff", {
+ hash,
+ exitCode: result.code,
+ stderr: result.stderr,
+ })
+ return ""
+ }
+ return result.text.trim()
+ })
+
+ const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
+ const result: Snapshot.FileDiff[] = []
+ const status = new Map<string, "added" | "deleted" | "modified">()
+
+ const statuses = yield* git(
+ [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
+ { cwd: state.directory },
+ )
+
+ for (const line of statuses.text.trim().split("\n")) {
+ if (!line) continue
+ const [code, file] = line.split("\t")
+ if (!code || !file) continue
+ status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+ }
+
+ const numstat = yield* git(
+ [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+ {
+ cwd: state.directory,
+ },
+ )
+
+ for (const line of numstat.text.trim().split("\n")) {
+ if (!line) continue
+ const [adds, dels, file] = line.split("\t")
+ if (!file) continue
+ const binary = adds === "-" && dels === "-"
+ const [before, after] = binary
+ ? ["", ""]
+ : yield* Effect.all(
+ [
+ git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
+ git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
+ ],
+ { concurrency: 2 },
+ )
+ const additions = binary ? 0 : parseInt(adds)
+ const deletions = binary ? 0 : parseInt(dels)
+ result.push({
+ file,
+ before,
+ after,
+ additions: Number.isFinite(additions) ? additions : 0,
+ deletions: Number.isFinite(deletions) ? deletions : 0,
+ status: status.get(file) ?? "modified",
+ })
+ }
+
+ return result
+ })
+
+ yield* cleanup().pipe(
+ Effect.catchCause((cause) => {
+ log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
+ return Effect.void
+ }),
+ Effect.repeat(Schedule.spaced(Duration.hours(1))),
+ Effect.delay(Duration.minutes(1)),
+ Effect.forkScoped,
+ )
+
+ return { cleanup, track, patch, restore, revert, diff, diffFull }
+ }),
+ )
+
+ return Service.of({
+ init: Effect.fn("Snapshot.init")(function* () {
+ yield* InstanceState.get(state)
+ }),
+ cleanup: Effect.fn("Snapshot.cleanup")(function* () {
+ return yield* InstanceState.useEffect(state, (s) => s.cleanup())
+ }),
+ track: Effect.fn("Snapshot.track")(function* () {
+ return yield* InstanceState.useEffect(state, (s) => s.track())
+ }),
+ patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
+ return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
+ }),
+ restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+ return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
+ }),
+ revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
+ return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
+ }),
+ diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
+ return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
+ }),
+ diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+ return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
+ }),
+ })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(
+ Layer.provide(NodeChildProcessSpawner.layer),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
+ Layer.provide(NodePath.layer),
+ )
+
+ const runPromise = makeRunPromise(Service, defaultLayer)
+
+ export async function init() {
+ return runPromise((svc) => svc.init())
+ }
export async function cleanup() {
- return runPromiseInstance(S.Service.use((svc) => svc.cleanup()))
+ return runPromise((svc) => svc.cleanup())
}
export async function track() {
- return runPromiseInstance(S.Service.use((svc) => svc.track()))
+ return runPromise((svc) => svc.track())
}
export async function patch(hash: string) {
- return runPromiseInstance(S.Service.use((svc) => svc.patch(hash)))
+ return runPromise((svc) => svc.patch(hash))
}
export async function restore(snapshot: string) {
- return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot)))
+ return runPromise((svc) => svc.restore(snapshot))
}
export async function revert(patches: Patch[]) {
- return runPromiseInstance(S.Service.use((svc) => svc.revert(patches)))
+ return runPromise((svc) => svc.revert(patches))
}
export async function diff(hash: string) {
- return runPromiseInstance(S.Service.use((svc) => svc.diff(hash)))
+ return runPromise((svc) => svc.diff(hash))
}
export async function diffFull(from: string, to: string) {
- return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to)))
+ return runPromise((svc) => svc.diffFull(from, to))
}
}
diff --git a/packages/opencode/src/snapshot/service.ts b/packages/opencode/src/snapshot/service.ts
deleted file mode 100644
index 50485d0a7..000000000
--- a/packages/opencode/src/snapshot/service.ts
+++ /dev/null
@@ -1,320 +0,0 @@
-import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import path from "path"
-import z from "zod"
-import { InstanceContext } from "@/effect/instance-context"
-import { AppFileSystem } from "@/filesystem"
-import { Config } from "../config/config"
-import { Global } from "../global"
-import { Log } from "../util/log"
-
-export namespace Snapshot {
- export const Patch = z.object({
- hash: z.string(),
- files: z.string().array(),
- })
- export type Patch = z.infer<typeof Patch>
-
- export const FileDiff = z
- .object({
- file: z.string(),
- before: z.string(),
- after: z.string(),
- additions: z.number(),
- deletions: z.number(),
- status: z.enum(["added", "deleted", "modified"]).optional(),
- })
- .meta({
- ref: "FileDiff",
- })
- export type FileDiff = z.infer<typeof FileDiff>
-
- const log = Log.create({ service: "snapshot" })
- const prune = "7.days"
- const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
- const cfg = ["-c", "core.autocrlf=false", ...core]
- const quote = [...cfg, "-c", "core.quotepath=false"]
-
- interface GitResult {
- readonly code: ChildProcessSpawner.ExitCode
- readonly text: string
- readonly stderr: string
- }
-
- export interface Interface {
- readonly cleanup: () => Effect.Effect<void>
- readonly track: () => Effect.Effect<string | undefined>
- readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
- readonly restore: (snapshot: string) => Effect.Effect<void>
- readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
- readonly diff: (hash: string) => Effect.Effect<string>
- readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
-
- export const layer: Layer.Layer<
- Service,
- never,
- InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
- > = Layer.effect(
- Service,
- Effect.gen(function* () {
- const ctx = yield* InstanceContext
- const fs = yield* AppFileSystem.Service
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
- const directory = ctx.directory
- const worktree = ctx.worktree
- const project = ctx.project
- const gitdir = path.join(Global.Path.data, "snapshot", project.id)
-
- const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
-
- const git = Effect.fnUntraced(
- function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
- const proc = ChildProcess.make("git", cmd, {
- cwd: opts?.cwd,
- env: opts?.env,
- extendEnv: true,
- })
- const handle = yield* spawner.spawn(proc)
- const [text, stderr] = yield* Effect.all(
- [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
- { concurrency: 2 },
- )
- const code = yield* handle.exitCode
- return { code, text, stderr } satisfies GitResult
- },
- Effect.scoped,
- Effect.catch((err) =>
- Effect.succeed({
- code: ChildProcessSpawner.ExitCode(1),
- text: "",
- stderr: String(err),
- }),
- ),
- )
-
- // Snapshot-specific error handling on top of AppFileSystem
- const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
- const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
- const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
-
- const enabled = Effect.fnUntraced(function* () {
- if (project.vcs !== "git") return false
- return (yield* Effect.promise(() => Config.get())).snapshot !== false
- })
-
- const excludes = Effect.fnUntraced(function* () {
- const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
- cwd: worktree,
- })
- const file = result.text.trim()
- if (!file) return
- if (!(yield* exists(file))) return
- return file
- })
-
- const sync = Effect.fnUntraced(function* () {
- const file = yield* excludes()
- const target = path.join(gitdir, "info", "exclude")
- yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
- if (!file) {
- yield* fs.writeFileString(target, "").pipe(Effect.orDie)
- return
- }
- yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
- })
-
- const add = Effect.fnUntraced(function* () {
- yield* sync()
- yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
- })
-
- const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
- if (!(yield* enabled())) return
- if (!(yield* exists(gitdir))) return
- const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
- if (result.code !== 0) {
- log.warn("cleanup failed", {
- exitCode: result.code,
- stderr: result.stderr,
- })
- return
- }
- log.info("cleanup", { prune })
- })
-
- const track = Effect.fn("Snapshot.track")(function* () {
- if (!(yield* enabled())) return
- const existed = yield* exists(gitdir)
- yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
- if (!existed) {
- yield* git(["init"], {
- env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
- })
- yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
- yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
- yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
- yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
- log.info("initialized")
- }
- yield* add()
- const result = yield* git(args(["write-tree"]), { cwd: directory })
- const hash = result.text.trim()
- log.info("tracking", { hash, cwd: directory, git: gitdir })
- return hash
- })
-
- const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
- yield* add()
- const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
- cwd: directory,
- })
- if (result.code !== 0) {
- log.warn("failed to get diff", { hash, exitCode: result.code })
- return { hash, files: [] }
- }
- return {
- hash,
- files: result.text
- .trim()
- .split("\n")
- .map((x) => x.trim())
- .filter(Boolean)
- .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
- }
- })
-
- const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
- log.info("restore", { commit: snapshot })
- const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
- if (result.code === 0) {
- const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
- if (checkout.code === 0) return
- log.error("failed to restore snapshot", {
- snapshot,
- exitCode: checkout.code,
- stderr: checkout.stderr,
- })
- return
- }
- log.error("failed to restore snapshot", {
- snapshot,
- exitCode: result.code,
- stderr: result.stderr,
- })
- })
-
- const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
- const seen = new Set<string>()
- for (const item of patches) {
- for (const file of item.files) {
- if (seen.has(file)) continue
- seen.add(file)
- log.info("reverting", { file, hash: item.hash })
- const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
- if (result.code !== 0) {
- const rel = path.relative(worktree, file)
- const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree })
- if (tree.code === 0 && tree.text.trim()) {
- log.info("file existed in snapshot but checkout failed, keeping", { file })
- } else {
- log.info("file did not exist in snapshot, deleting", { file })
- yield* remove(file)
- }
- }
- }
- }
- })
-
- const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
- yield* add()
- const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
- cwd: worktree,
- })
- if (result.code !== 0) {
- log.warn("failed to get diff", {
- hash,
- exitCode: result.code,
- stderr: result.stderr,
- })
- return ""
- }
- return result.text.trim()
- })
-
- const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
- const result: Snapshot.FileDiff[] = []
- const status = new Map<string, "added" | "deleted" | "modified">()
-
- const statuses = yield* git(
- [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
- { cwd: directory },
- )
-
- for (const line of statuses.text.trim().split("\n")) {
- if (!line) continue
- const [code, file] = line.split("\t")
- if (!code || !file) continue
- status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
- }
-
- const numstat = yield* git(
- [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
- {
- cwd: directory,
- },
- )
-
- for (const line of numstat.text.trim().split("\n")) {
- if (!line) continue
- const [adds, dels, file] = line.split("\t")
- if (!file) continue
- const binary = adds === "-" && dels === "-"
- const [before, after] = binary
- ? ["", ""]
- : yield* Effect.all(
- [
- git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
- git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
- ],
- { concurrency: 2 },
- )
- const additions = binary ? 0 : parseInt(adds)
- const deletions = binary ? 0 : parseInt(dels)
- result.push({
- file,
- before,
- after,
- additions: Number.isFinite(additions) ? additions : 0,
- deletions: Number.isFinite(deletions) ? deletions : 0,
- status: status.get(file) ?? "modified",
- })
- }
-
- return result
- })
-
- yield* cleanup().pipe(
- Effect.catchCause((cause) => {
- log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
- return Effect.void
- }),
- Effect.repeat(Schedule.spaced(Duration.hours(1))),
- Effect.delay(Duration.minutes(1)),
- Effect.forkScoped,
- )
-
- return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
- }),
- ).pipe(Layer.fresh)
-
- export const defaultLayer = layer.pipe(
- Layer.provide(NodeChildProcessSpawner.layer),
- Layer.provide(AppFileSystem.defaultLayer),
- Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
- Layer.provide(NodePath.layer),
- )
-}
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index 66c8b181b..06293b6eb 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -12,7 +12,7 @@ import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./apply_patch.txt"
-import { File } from "../file/service"
+import { File } from "../file"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 982095cd5..1a7614fc1 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -9,13 +9,13 @@ import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./edit.txt"
-import { File } from "../file/service"
+import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Bus } from "../bus"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
-import { Snapshot } from "@/snapshot/service"
+import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
index 27a988e56..a2887546d 100644
--- a/packages/opencode/src/tool/question.ts
+++ b/packages/opencode/src/tool/question.ts
@@ -1,7 +1,6 @@
import z from "zod"
import { Tool } from "./tool"
-import { Question } from "../question/service"
-import { Question as QuestionApi } from "../question"
+import { Question } from "../question"
import DESCRIPTION from "./question.txt"
export const QuestionTool = Tool.define("question", {
@@ -10,7 +9,7 @@ export const QuestionTool = Tool.define("question", {
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
}),
async execute(params, ctx) {
- const answers = await QuestionApi.ask({
+ const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 79bec7560..e3781126d 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
-import { Permission as PermissionNext } from "@/permission/service"
+import { Permission } from "@/permission"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
@@ -31,7 +31,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
- ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
+ ? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts
index c34bdbc50..6c3f4efaf 100644
--- a/packages/opencode/src/tool/tool.ts
+++ b/packages/opencode/src/tool/tool.ts
@@ -1,7 +1,7 @@
import z from "zod"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
-import type { Permission as PermissionNext } from "../permission/service"
+import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
@@ -23,7 +23,7 @@ export namespace Tool {
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void
- ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
+ ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts
deleted file mode 100644
index 1b4c6577f..000000000
--- a/packages/opencode/src/tool/truncate-effect.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
-import path from "path"
-import type { Agent } from "../agent/agent"
-import { AppFileSystem } from "@/filesystem"
-import { evaluate } from "@/permission/evaluate"
-import { Identifier } from "../id/id"
-import { Log } from "../util/log"
-import { ToolID } from "./schema"
-import { TRUNCATION_DIR } from "./truncation-dir"
-
-export namespace Truncate {
- const log = Log.create({ service: "truncation" })
- const RETENTION = Duration.days(7)
-
- export const MAX_LINES = 2000
- export const MAX_BYTES = 50 * 1024
- export const DIR = TRUNCATION_DIR
- export const GLOB = path.join(TRUNCATION_DIR, "*")
-
- export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
-
- export interface Options {
- maxLines?: number
- maxBytes?: number
- direction?: "head" | "tail"
- }
-
- function hasTaskTool(agent?: Agent.Info) {
- if (!agent?.permission) return false
- return evaluate("task", "*", agent.permission).action !== "deny"
- }
-
- export interface Interface {
- readonly cleanup: () => Effect.Effect<void>
- /**
- * Returns output unchanged when it fits within the limits, otherwise writes the full text
- * to the truncation directory and returns a preview plus a hint to inspect the saved file.
- */
- readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
- }
-
- export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
-
- const cleanup = Effect.fn("Truncate.cleanup")(function* () {
- const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
- const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
- Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
- Effect.catch(() => Effect.succeed([])),
- )
- for (const entry of entries) {
- if (Identifier.timestamp(entry) >= cutoff) continue
- yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
- }
- })
-
- const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
- const maxLines = options.maxLines ?? MAX_LINES
- const maxBytes = options.maxBytes ?? MAX_BYTES
- const direction = options.direction ?? "head"
- const lines = text.split("\n")
- const totalBytes = Buffer.byteLength(text, "utf-8")
-
- if (lines.length <= maxLines && totalBytes <= maxBytes) {
- return { content: text, truncated: false } as const
- }
-
- const out: string[] = []
- let i = 0
- let bytes = 0
- let hitBytes = false
-
- if (direction === "head") {
- for (i = 0; i < lines.length && i < maxLines; i++) {
- const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
- if (bytes + size > maxBytes) {
- hitBytes = true
- break
- }
- out.push(lines[i])
- bytes += size
- }
- } else {
- for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
- const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
- if (bytes + size > maxBytes) {
- hitBytes = true
- break
- }
- out.unshift(lines[i])
- bytes += size
- }
- }
-
- const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
- const unit = hitBytes ? "bytes" : "lines"
- const preview = out.join("\n")
- const file = path.join(TRUNCATION_DIR, ToolID.ascending())
-
- yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
- yield* fs.writeFileString(file, text).pipe(Effect.orDie)
-
- const hint = hasTaskTool(agent)
- ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
- : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
-
- return {
- content:
- direction === "head"
- ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
- : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
- truncated: true,
- outputPath: file,
- } as const
- })
-
- yield* cleanup().pipe(
- Effect.catchCause((cause) => {
- log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
- return Effect.void
- }),
- Effect.repeat(Schedule.spaced(Duration.hours(1))),
- Effect.delay(Duration.minutes(1)),
- Effect.forkScoped,
- )
-
- return Service.of({ cleanup, output })
- }),
- )
-
- export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
-}
diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts
index 171054638..fa1d0a4ae 100644
--- a/packages/opencode/src/tool/truncate.ts
+++ b/packages/opencode/src/tool/truncate.ts
@@ -1,18 +1,144 @@
+import { NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
+import path from "path"
import type { Agent } from "../agent/agent"
-import { runtime } from "@/effect/runtime"
-import { Truncate as S } from "./truncate-effect"
+import { makeRunPromise } from "@/effect/run-service"
+import { AppFileSystem } from "@/filesystem"
+import { evaluate } from "@/permission/evaluate"
+import { Identifier } from "../id/id"
+import { Log } from "../util/log"
+import { ToolID } from "./schema"
+import { TRUNCATION_DIR } from "./truncation-dir"
export namespace Truncate {
- export const MAX_LINES = S.MAX_LINES
- export const MAX_BYTES = S.MAX_BYTES
- export const DIR = S.DIR
- export const GLOB = S.GLOB
+ const log = Log.create({ service: "truncation" })
+ const RETENTION = Duration.days(7)
- export type Result = S.Result
+ export const MAX_LINES = 2000
+ export const MAX_BYTES = 50 * 1024
+ export const DIR = TRUNCATION_DIR
+ export const GLOB = path.join(TRUNCATION_DIR, "*")
- export type Options = S.Options
+ export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+ export interface Options {
+ maxLines?: number
+ maxBytes?: number
+ direction?: "head" | "tail"
+ }
+
+ function hasTaskTool(agent?: Agent.Info) {
+ if (!agent?.permission) return false
+ return evaluate("task", "*", agent.permission).action !== "deny"
+ }
+
+ export interface Interface {
+ readonly cleanup: () => Effect.Effect<void>
+ /**
+ * Returns output unchanged when it fits within the limits, otherwise writes the full text
+ * to the truncation directory and returns a preview plus a hint to inspect the saved file.
+ */
+ readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+
+ const cleanup = Effect.fn("Truncate.cleanup")(function* () {
+ const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
+ const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
+ Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
+ Effect.catch(() => Effect.succeed([])),
+ )
+ for (const entry of entries) {
+ if (Identifier.timestamp(entry) >= cutoff) continue
+ yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
+ }
+ })
+
+ const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
+ const maxLines = options.maxLines ?? MAX_LINES
+ const maxBytes = options.maxBytes ?? MAX_BYTES
+ const direction = options.direction ?? "head"
+ const lines = text.split("\n")
+ const totalBytes = Buffer.byteLength(text, "utf-8")
+
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
+ return { content: text, truncated: false } as const
+ }
+
+ const out: string[] = []
+ let i = 0
+ let bytes = 0
+ let hitBytes = false
+
+ if (direction === "head") {
+ for (i = 0; i < lines.length && i < maxLines; i++) {
+ const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+ if (bytes + size > maxBytes) {
+ hitBytes = true
+ break
+ }
+ out.push(lines[i])
+ bytes += size
+ }
+ } else {
+ for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+ const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+ if (bytes + size > maxBytes) {
+ hitBytes = true
+ break
+ }
+ out.unshift(lines[i])
+ bytes += size
+ }
+ }
+
+ const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+ const unit = hitBytes ? "bytes" : "lines"
+ const preview = out.join("\n")
+ const file = path.join(TRUNCATION_DIR, ToolID.ascending())
+
+ yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
+ yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+
+ const hint = hasTaskTool(agent)
+ ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+ : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+
+ return {
+ content:
+ direction === "head"
+ ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+ : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
+ truncated: true,
+ outputPath: file,
+ } as const
+ })
+
+ yield* cleanup().pipe(
+ Effect.catchCause((cause) => {
+ log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
+ return Effect.void
+ }),
+ Effect.repeat(Schedule.spaced(Duration.hours(1))),
+ Effect.delay(Duration.minutes(1)),
+ Effect.forkScoped,
+ )
+
+ return Service.of({ cleanup, output })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
+
+ const runPromise = makeRunPromise(Service, defaultLayer)
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
- return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
+ return runPromise((s) => s.output(text, options, agent))
}
}
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index abfab6d48..83474a543 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -5,7 +5,7 @@ import { LSP } from "../lsp"
import { createTwoFilesPatch } from "diff"
import DESCRIPTION from "./write.txt"
import { Bus } from "../bus"
-import { File } from "../file/service"
+import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index f5436e514..9c67641d2 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/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 { Account } from "../../src/account/effect"
+import { Account } from "../../src/account"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect"
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 60c8e57c9..2805cf261 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -1,16 +1,20 @@
-import { test, expect } from "bun:test"
+import { afterEach, test, expect } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
-import { PermissionNext } from "../../src/permission"
+import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
-function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
+function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
if (!agent) return undefined
- return PermissionNext.evaluate(permission, "*", agent.permission).action
+ return Permission.evaluate(permission, "*", agent.permission).action
}
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
- expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
+ expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
},
})
})
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
- expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
- expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
+ expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
+ expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
},
})
})
@@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
// Specific pattern is denied
- expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
},
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
- expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
- expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
- expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
+ expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
- expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
- expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
- expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
+ expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
- expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
- expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
+ expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
},
})
})
@@ -582,7 +586,7 @@ description: Permission skill.
const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md")
- expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
+ expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
},
})
} finally {
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index baf209d86..eb9c763fa 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
const originalToken = Account.token
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
- Account.active = mock(() => ({
+ Account.active = mock(async () => ({
id: AccountID.make("account-1"),
url: "https://control.example.com",
diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts
new file mode 100644
index 000000000..2d527482b
--- /dev/null
+++ b/packages/opencode/test/effect/instance-state.test.ts
@@ -0,0 +1,384 @@
+import { afterEach, expect, test } from "bun:test"
+import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
+import { InstanceState } from "../../src/effect/instance-state"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+async function access<A, E>(state: InstanceState<A, E>, dir: string) {
+ return Instance.provide({
+ directory: dir,
+ fn: () => Effect.runPromise(InstanceState.get(state)),
+ })
+}
+
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
+test("InstanceState caches values per directory", async () => {
+ await using tmp = await tmpdir()
+ let n = 0
+
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
+
+ const a = yield* Effect.promise(() => access(state, tmp.path))
+ const b = yield* Effect.promise(() => access(state, tmp.path))
+
+ expect(a).toBe(b)
+ expect(n).toBe(1)
+ }),
+ ),
+ )
+})
+
+test("InstanceState isolates directories", async () => {
+ await using one = await tmpdir()
+ await using two = await tmpdir()
+ let n = 0
+
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
+
+ const a = yield* Effect.promise(() => access(state, one.path))
+ const b = yield* Effect.promise(() => access(state, two.path))
+ const c = yield* Effect.promise(() => access(state, one.path))
+
+ expect(a).toBe(c)
+ expect(a).not.toBe(b)
+ expect(n).toBe(2)
+ }),
+ ),
+ )
+})
+
+test("InstanceState invalidates on reload", async () => {
+ await using tmp = await tmpdir()
+ const seen: string[] = []
+ let n = 0
+
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make(() =>
+ Effect.acquireRelease(
+ Effect.sync(() => ({ n: ++n })),
+ (value) =>
+ Effect.sync(() => {
+ seen.push(String(value.n))
+ }),
+ ),
+ )
+
+ const a = yield* Effect.promise(() => access(state, tmp.path))
+ yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
+ const b = yield* Effect.promise(() => access(state, tmp.path))
+
+ expect(a).not.toBe(b)
+ expect(seen).toEqual(["1"])
+ }),
+ ),
+ )
+})
+
+test("InstanceState invalidates on disposeAll", async () => {
+ await using one = await tmpdir()
+ await using two = await tmpdir()
+ const seen: string[] = []
+
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((ctx) =>
+ Effect.acquireRelease(
+ Effect.sync(() => ({ dir: ctx.directory })),
+ (value) =>
+ Effect.sync(() => {
+ seen.push(value.dir)
+ }),
+ ),
+ )
+
+ yield* Effect.promise(() => access(state, one.path))
+ yield* Effect.promise(() => access(state, two.path))
+ yield* Effect.promise(() => Instance.disposeAll())
+
+ expect(seen.sort()).toEqual([one.path, two.path].sort())
+ }),
+ ),
+ )
+})
+
+test("InstanceState.get reads the current directory lazily", async () => {
+ await using one = await tmpdir()
+ await using two = await tmpdir()
+
+ interface Api {
+ readonly get: () => Effect.Effect<string>
+ }
+
+ class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
+ static readonly layer = Layer.effect(
+ Test,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
+ const get = InstanceState.get(state)
+
+ return Test.of({
+ get: Effect.fn("Test.get")(function* () {
+ return yield* get
+ }),
+ })
+ }),
+ )
+ }
+
+ const rt = ManagedRuntime.make(Test.layer)
+
+ try {
+ const a = await Instance.provide({
+ directory: one.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ })
+ const b = await Instance.provide({
+ directory: two.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ })
+
+ expect(a).toBe(one.path)
+ expect(b).toBe(two.path)
+ } finally {
+ await rt.dispose()
+ }
+})
+
+test("InstanceState preserves directory across async boundaries", async () => {
+ await using one = await tmpdir({ git: true })
+ await using two = await tmpdir({ git: true })
+ await using three = await tmpdir({ git: true })
+
+ interface Api {
+ readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
+ }
+
+ class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
+ static readonly layer = Layer.effect(
+ Test,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((ctx) =>
+ Effect.sync(() => ({
+ directory: ctx.directory,
+ worktree: ctx.worktree,
+ project: ctx.project.id,
+ })),
+ )
+
+ return Test.of({
+ get: Effect.fn("Test.get")(function* () {
+ yield* Effect.promise(() => Bun.sleep(1))
+ yield* Effect.sleep(Duration.millis(1))
+ for (let i = 0; i < 100; i++) {
+ yield* Effect.yieldNow
+ }
+ for (let i = 0; i < 100; i++) {
+ yield* Effect.promise(() => Promise.resolve())
+ }
+ yield* Effect.sleep(Duration.millis(2))
+ yield* Effect.promise(() => Bun.sleep(1))
+ return yield* InstanceState.get(state)
+ }),
+ })
+ }),
+ )
+ }
+
+ const rt = ManagedRuntime.make(Test.layer)
+
+ try {
+ const [a, b, c] = await Promise.all([
+ Instance.provide({
+ directory: one.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ }),
+ Instance.provide({
+ directory: two.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ }),
+ Instance.provide({
+ directory: three.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ }),
+ ])
+
+ expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
+ expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
+ expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
+ expect(a.project).not.toBe(b.project)
+ expect(a.project).not.toBe(c.project)
+ expect(b.project).not.toBe(c.project)
+ } finally {
+ await rt.dispose()
+ }
+})
+
+test("InstanceState survives high-contention concurrent access", async () => {
+ const N = 20
+ const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
+
+ interface Api {
+ readonly get: () => Effect.Effect<string>
+ }
+
+ class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
+ static readonly layer = Layer.effect(
+ Test,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
+
+ return Test.of({
+ get: Effect.fn("Test.get")(function* () {
+ // Interleave many async hops to maximize chance of ALS corruption
+ for (let i = 0; i < 10; i++) {
+ yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
+ yield* Effect.yieldNow
+ yield* Effect.promise(() => Promise.resolve())
+ }
+ return yield* InstanceState.get(state)
+ }),
+ })
+ }),
+ )
+ }
+
+ const rt = ManagedRuntime.make(Test.layer)
+
+ try {
+ const results = await Promise.all(
+ dirs.map((d) =>
+ Instance.provide({
+ directory: d.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ }),
+ ),
+ )
+
+ for (let i = 0; i < N; i++) {
+ expect(results[i]).toBe(dirs[i].path)
+ }
+ } finally {
+ await rt.dispose()
+ for (const d of dirs) await d[Symbol.asyncDispose]()
+ }
+})
+
+test("InstanceState correct after interleaved init and dispose", async () => {
+ await using one = await tmpdir()
+ await using two = await tmpdir()
+
+ interface Api {
+ readonly get: () => Effect.Effect<string>
+ }
+
+ class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
+ static readonly layer = Layer.effect(
+ Test,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((ctx) =>
+ Effect.promise(async () => {
+ await Bun.sleep(5) // slow init
+ return ctx.directory
+ }),
+ )
+
+ return Test.of({
+ get: Effect.fn("Test.get")(function* () {
+ return yield* InstanceState.get(state)
+ }),
+ })
+ }),
+ )
+ }
+
+ const rt = ManagedRuntime.make(Test.layer)
+
+ try {
+ // Init both directories
+ const a = await Instance.provide({
+ directory: one.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ })
+ expect(a).toBe(one.path)
+
+ // Dispose one directory, access the other concurrently
+ const [, b] = await Promise.all([
+ Instance.reload({ directory: one.path }),
+ Instance.provide({
+ directory: two.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ }),
+ ])
+ expect(b).toBe(two.path)
+
+ // Re-access disposed directory - should get fresh state
+ const c = await Instance.provide({
+ directory: one.path,
+ fn: () => rt.runPromise(Test.use((svc) => svc.get())),
+ })
+ expect(c).toBe(one.path)
+ } finally {
+ await rt.dispose()
+ }
+})
+
+test("InstanceState mutation in one directory does not leak to another", async () => {
+ await using one = await tmpdir()
+ await using two = await tmpdir()
+
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
+
+ // Mutate state in directory one
+ const s1 = yield* Effect.promise(() => access(state, one.path))
+ s1.count = 42
+
+ // Access directory two — should be independent
+ const s2 = yield* Effect.promise(() => access(state, two.path))
+ expect(s2.count).toBe(0)
+
+ // Confirm directory one still has the mutation
+ const s1again = yield* Effect.promise(() => access(state, one.path))
+ expect(s1again.count).toBe(42)
+ expect(s1again).toBe(s1) // same reference
+ }),
+ ),
+ )
+})
+
+test("InstanceState dedupes concurrent lookups", async () => {
+ await using tmp = await tmpdir()
+ let n = 0
+
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make(() =>
+ Effect.promise(async () => {
+ n += 1
+ await Bun.sleep(10)
+ return { n }
+ }),
+ )
+
+ const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
+ expect(a).toBe(b)
+ expect(n).toBe(1)
+ }),
+ ),
+ )
+})
diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts
new file mode 100644
index 000000000..c9f630585
--- /dev/null
+++ b/packages/opencode/test/effect/run-service.test.ts
@@ -0,0 +1,46 @@
+import { expect, test } from "bun:test"
+import { Effect, Layer, ServiceMap } from "effect"
+import { makeRunPromise } from "../../src/effect/run-service"
+
+class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
+
+test("makeRunPromise shares dependent layers through the shared memo map", async () => {
+ let n = 0
+
+ const shared = Layer.effect(
+ Shared,
+ Effect.sync(() => {
+ n += 1
+ return Shared.of({ id: n })
+ }),
+ )
+
+ class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
+ const one = Layer.effect(
+ One,
+ Effect.gen(function* () {
+ const svc = yield* Shared
+ return One.of({
+ get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
+ })
+ }),
+ ).pipe(Layer.provide(shared))
+
+ class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
+ const two = Layer.effect(
+ Two,
+ Effect.gen(function* () {
+ const svc = yield* Shared
+ return Two.of({
+ get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
+ })
+ }),
+ ).pipe(Layer.provide(shared))
+
+ const runOne = makeRunPromise(One, one)
+ const runTwo = makeRunPromise(Two, two)
+
+ expect(await runOne((svc) => svc.get())).toBe(1)
+ expect(await runTwo((svc) => svc.get())).toBe(1)
+ expect(n).toBe(1)
+})
diff --git a/packages/opencode/test/effect/runtime.test.ts b/packages/opencode/test/effect/runtime.test.ts
deleted file mode 100644
index 70bf29aaf..000000000
--- a/packages/opencode/test/effect/runtime.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test"
-import { Effect } from "effect"
-import { runtime, runPromiseInstance } from "../../src/effect/runtime"
-import { Auth } from "../../src/auth/effect"
-import { Instances } from "../../src/effect/instances"
-import { Instance } from "../../src/project/instance"
-import { ProviderAuth } from "../../src/provider/auth"
-import { Vcs } from "../../src/project/vcs"
-import { Question } from "../../src/question"
-import { tmpdir } from "../fixture/fixture"
-
-/**
- * Integration tests for the Effect runtime and LayerMap-based instance system.
- *
- * Each instance service layer has `.pipe(Layer.fresh)` at its definition site
- * so it is always rebuilt per directory, while shared dependencies are provided
- * outside the fresh boundary and remain memoizable.
- *
- * These tests verify the invariants using object identity (===) on the real
- * production services — not mock services or return-value checks.
- */
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
-
-describe("effect/runtime", () => {
- afterEach(async () => {
- await Instance.disposeAll()
- })
-
- test("global services are shared across directories", async () => {
- await using one = await tmpdir({ git: true })
- await using two = await tmpdir({ git: true })
-
- // Auth is a global service — it should be the exact same object
- // regardless of which directory we're in.
- const authOne = await Instance.provide({
- directory: one.path,
- fn: () => grabGlobal(Auth.Service),
- })
-
- const authTwo = await Instance.provide({
- directory: two.path,
- fn: () => grabGlobal(Auth.Service),
- })
-
- expect(authOne).toBe(authTwo)
- })
-
- test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
- await using one = await tmpdir({ git: true })
- await using two = await tmpdir({ git: true })
-
- // ProviderAuth depends on Auth via defaultLayer.
- // The instance service itself should be different per directory,
- // but the underlying Auth should be shared.
- const paOne = await Instance.provide({
- directory: one.path,
- fn: () => grabInstance(ProviderAuth.Service),
- })
-
- const paTwo = await Instance.provide({
- directory: two.path,
- fn: () => grabInstance(ProviderAuth.Service),
- })
-
- // Different directories → different ProviderAuth instances.
- expect(paOne).not.toBe(paTwo)
-
- // But the global Auth is the same object in both.
- const authOne = await Instance.provide({
- directory: one.path,
- fn: () => grabGlobal(Auth.Service),
- })
- const authTwo = await Instance.provide({
- directory: two.path,
- fn: () => grabGlobal(Auth.Service),
- })
- expect(authOne).toBe(authTwo)
- })
-
- test("instance services are shared within the same directory", async () => {
- await using tmp = await tmpdir({ git: true })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
- expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
- },
- })
- })
-
- test("different directories get different service instances", async () => {
- await using one = await tmpdir({ git: true })
- await using two = await tmpdir({ git: true })
-
- const vcsOne = await Instance.provide({
- directory: one.path,
- fn: () => grabInstance(Vcs.Service),
- })
-
- const vcsTwo = await Instance.provide({
- directory: two.path,
- fn: () => grabInstance(Vcs.Service),
- })
-
- expect(vcsOne).not.toBe(vcsTwo)
- })
-
- test("disposal rebuilds services with a new instance", async () => {
- await using tmp = await tmpdir({ git: true })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const before = await grabInstance(Question.Service)
-
- await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
-
- const after = await grabInstance(Question.Service)
- expect(after).not.toBe(before)
- },
- })
- })
-})
diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts
index 8f4cbe868..fae3ac1f2 100644
--- a/packages/opencode/test/file/index.test.ts
+++ b/packages/opencode/test/file/index.test.ts
@@ -1,4 +1,4 @@
-import { describe, test, expect } from "bun:test"
+import { afterEach, describe, test, expect } from "bun:test"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
describe("file/index Filesystem patterns", () => {
describe("File.read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => {
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
})
})
+ test("search works before explicit init", async () => {
+ await using tmp = await setupSearchableRepo()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.search({ query: "main", type: "file" })
+ expect(result.some((f) => f.includes("main"))).toBe(true)
+ },
+ })
+ })
+
test("empty query returns dirs sorted with hidden last", async () => {
await using tmp = await setupSearchableRepo()
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
},
})
})
+
+ test("search refreshes after init when files change", async () => {
+ await using tmp = await setupSearchableRepo()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await File.init()
+ expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
+
+ await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
+
+ const result = await File.search({ query: "fresh", type: "file" })
+ expect(result).toContain("fresh.ts")
+ },
+ })
+ })
})
describe("File.read() - diff/patch", () => {
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
})
})
})
+
+ describe("InstanceState isolation", () => {
+ test("two directories get independent file caches", async () => {
+ await using one = await tmpdir({ git: true })
+ await using two = await tmpdir({ git: true })
+ await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
+ await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
+
+ await Instance.provide({
+ directory: one.path,
+ fn: async () => {
+ await File.init()
+ const results = await File.search({ query: "a.ts", type: "file" })
+ expect(results).toContain("a.ts")
+ const results2 = await File.search({ query: "b.ts", type: "file" })
+ expect(results2).not.toContain("b.ts")
+ },
+ })
+
+ await Instance.provide({
+ directory: two.path,
+ fn: async () => {
+ await File.init()
+ const results = await File.search({ query: "b.ts", type: "file" })
+ expect(results).toContain("b.ts")
+ const results2 = await File.search({ query: "a.ts", type: "file" })
+ expect(results2).not.toContain("a.ts")
+ },
+ })
+ })
+
+ test("disposal gives fresh state on next access", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await File.init()
+ const results = await File.search({ query: "before", type: "file" })
+ expect(results).toContain("before.ts")
+ },
+ })
+
+ await Instance.disposeAll()
+
+ await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
+ await fs.rm(path.join(tmp.path, "before.ts"))
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await File.init()
+ const results = await File.search({ query: "after", type: "file" })
+ expect(results).toContain("after.ts")
+ const stale = await File.search({ query: "before", type: "file" })
+ expect(stale).not.toContain("before.ts")
+ },
+ })
+ })
+ })
})
diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts
index fbf8d5cd1..db7eaaae0 100644
--- a/packages/opencode/test/file/time.test.ts
+++ b/packages/opencode/test/file/time.test.ts
@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
-afterEach(() => Instance.disposeAll())
+afterEach(async () => {
+ await Instance.disposeAll()
+})
async function touch(file: string, time: number) {
const date = new Date(time)
@@ -84,6 +86,28 @@ describe("file/time", () => {
},
})
})
+
+ test("isolates reads by directory", async () => {
+ await using one = await tmpdir()
+ await using two = await tmpdir()
+ await using shared = await tmpdir()
+ const filepath = path.join(shared.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: one.path,
+ fn: async () => {
+ await FileTime.read(sessionID, filepath)
+ },
+ })
+
+ await Instance.provide({
+ directory: two.path,
+ fn: async () => {
+ expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
+ },
+ })
+ })
})
describe("assert()", () => {
diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts
index 2cd27643e..f4f0c1c7d 100644
--- a/packages/opencode/test/file/watcher.test.ts
+++ b/packages/opencode/test/file/watcher.test.ts
@@ -25,7 +25,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
directory,
FileWatcher.layer,
async (rt) => {
- await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
+ await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
@@ -136,7 +136,9 @@ function ready(directory: string) {
// ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => {
- afterEach(() => Instance.disposeAll())
+ afterEach(async () => {
+ await Instance.disposeAll()
+ })
test("publishes root create, update, and delete events", async () => {
await using tmp = await tmpdir({ git: true })
diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts
index 2718e125d..1992dede6 100644
--- a/packages/opencode/test/format/format.test.ts
+++ b/packages/opencode/test/format/format.test.ts
@@ -2,11 +2,16 @@ import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
+import { Bus } from "../../src/bus"
+import { File } from "../../src/file"
import { Format } from "../../src/format"
+import * as Formatter from "../../src/format/formatter"
import { Instance } from "../../src/project/instance"
describe("Format", () => {
- afterEach(() => Instance.disposeAll())
+ afterEach(async () => {
+ await Instance.disposeAll()
+ })
test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir()
@@ -62,4 +67,106 @@ describe("Format", () => {
await rt.runPromise(Format.Service.use(() => Effect.void))
})
})
+
+ test("status() initializes formatter state per directory", async () => {
+ await using off = await tmpdir({
+ config: { formatter: false },
+ })
+ await using on = await tmpdir()
+
+ const a = await Instance.provide({
+ directory: off.path,
+ fn: () => Format.status(),
+ })
+ const b = await Instance.provide({
+ directory: on.path,
+ fn: () => Format.status(),
+ })
+
+ expect(a).toEqual([])
+ expect(b.length).toBeGreaterThan(0)
+ })
+
+ test("runs enabled checks for matching formatters in parallel", async () => {
+ await using tmp = await tmpdir()
+
+ const file = `${tmp.path}/test.parallel`
+ await Bun.write(file, "x")
+
+ const one = {
+ extensions: Formatter.gofmt.extensions,
+ enabled: Formatter.gofmt.enabled,
+ command: Formatter.gofmt.command,
+ }
+ const two = {
+ extensions: Formatter.mix.extensions,
+ enabled: Formatter.mix.enabled,
+ command: Formatter.mix.command,
+ }
+
+ let active = 0
+ let max = 0
+
+ Formatter.gofmt.extensions = [".parallel"]
+ Formatter.mix.extensions = [".parallel"]
+ Formatter.gofmt.command = ["sh", "-c", "true"]
+ Formatter.mix.command = ["sh", "-c", "true"]
+ Formatter.gofmt.enabled = async () => {
+ active++
+ max = Math.max(max, active)
+ await Bun.sleep(20)
+ active--
+ return true
+ }
+ Formatter.mix.enabled = async () => {
+ active++
+ max = Math.max(max, active)
+ await Bun.sleep(20)
+ active--
+ return true
+ }
+
+ try {
+ await withServices(tmp.path, Format.layer, async (rt) => {
+ await rt.runPromise(Format.Service.use((s) => s.init()))
+ await Bus.publish(File.Event.Edited, { file })
+ })
+ } finally {
+ Formatter.gofmt.extensions = one.extensions
+ Formatter.gofmt.enabled = one.enabled
+ Formatter.gofmt.command = one.command
+ Formatter.mix.extensions = two.extensions
+ Formatter.mix.enabled = two.enabled
+ Formatter.mix.command = two.command
+ }
+
+ expect(max).toBe(2)
+ })
+
+ test("runs matching formatters sequentially for the same file", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ formatter: {
+ first: {
+ command: ["sh", "-c", "sleep 0.05; v=$(cat \"$1\"); printf '%sA' \"$v\" > \"$1\"", "sh", "$FILE"],
+ extensions: [".seq"],
+ },
+ second: {
+ command: ["sh", "-c", "v=$(cat \"$1\"); printf '%sB' \"$v\" > \"$1\"", "sh", "$FILE"],
+ extensions: [".seq"],
+ },
+ },
+ },
+ })
+
+ const file = `${tmp.path}/test.seq`
+ await Bun.write(file, "x")
+
+ await withServices(tmp.path, Format.layer, async (rt) => {
+ await rt.runPromise(Format.Service.use((s) => s.init()))
+ await Bus.publish(File.Event.Edited, { file })
+ })
+
+ expect(await Bun.file(file).text()).toBe("xAB")
+ })
})
diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts
index c78da6e6a..3ca32bf41 100644
--- a/packages/opencode/test/permission-task.test.ts
+++ b/packages/opencode/test/permission-task.test.ts
@@ -1,11 +1,15 @@
-import { describe, test, expect } from "bun:test"
-import { PermissionNext } from "../src/permission"
+import { afterEach, describe, test, expect } from "bun:test"
+import { Permission } from "../src/permission"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
-describe("PermissionNext.evaluate for permission.task", () => {
- const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
+describe("Permission.evaluate for permission.task", () => {
+ const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
}))
test("returns ask when no match (default)", () => {
- expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
+ expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
})
test("returns deny for explicit deny", () => {
const ruleset = createRuleset({ "code-reviewer": "deny" })
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
})
test("returns allow for explicit allow", () => {
const ruleset = createRuleset({ "code-reviewer": "allow" })
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
})
test("returns ask for explicit ask", () => {
const ruleset = createRuleset({ "code-reviewer": "ask" })
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
})
test("matches wildcard patterns with deny", () => {
const ruleset = createRuleset({ "orchestrator-*": "deny" })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
})
test("matches wildcard patterns with allow", () => {
const ruleset = createRuleset({ "orchestrator-*": "allow" })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
})
test("matches wildcard patterns with ask", () => {
const ruleset = createRuleset({ "orchestrator-*": "ask" })
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
const globalRuleset = createRuleset({ "*": "ask" })
- expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
})
test("later rules take precedence (last match wins)", () => {
@@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
"orchestrator-*": "deny",
"orchestrator-fast": "allow",
})
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
})
test("matches global wildcard", () => {
- expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
+ expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
+ expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
+ expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
})
})
-describe("PermissionNext.disabled for task tool", () => {
+describe("Permission.disabled for task tool", () => {
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
- const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
+ const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "allow",
"*": "deny",
})
- const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
+ const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true)
})
@@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "ask",
"*": "deny",
})
- const disabled = PermissionNext.disabled(["task"], ruleset)
+ const disabled = Permission.disabled(["task"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true)
})
test("task tool is disabled when global deny pattern exists", () => {
const ruleset = createRuleset({ "*": "deny" })
- const disabled = PermissionNext.disabled(["task"], ruleset)
+ const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
})
@@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "deny",
general: "deny",
})
- const disabled = PermissionNext.disabled(["task"], ruleset)
+ const disabled = Permission.disabled(["task"], ruleset)
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(false)
})
test("task tool is enabled when no task rules exist (default ask)", () => {
- const disabled = PermissionNext.disabled(["task"], [])
+ const disabled = Permission.disabled(["task"], [])
expect(disabled.has("task")).toBe(false)
})
@@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => {
"*": "deny",
"orchestrator-coder": "allow",
})
- const disabled = PermissionNext.disabled(["task"], ruleset)
+ const disabled = Permission.disabled(["task"], ruleset)
// The disabled() function uses findLast and checks if the last matching rule
// has pattern: "*" and action: "deny". In this case, the last rule matching
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
@@ -155,11 +159,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+ const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
},
})
})
@@ -180,11 +184,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+ const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
- expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
},
})
})
@@ -205,11 +209,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+ const ruleset = Permission.fromConfig(config.permission ?? {})
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
- expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
},
})
})
@@ -232,18 +236,18 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+ const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Verify other tool permissions
- expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
- expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
+ expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
// Verify disabled tools
- const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
+ const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
expect(disabled.has("bash")).toBe(false)
expect(disabled.has("edit")).toBe(false)
// task is NOT disabled because disabled() uses findLast, and the last rule
@@ -270,16 +274,16 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+ const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
- expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
// Since "*": "deny" is the last rule, disabled() finds it with findLast
// and sees pattern: "*" with action: "deny", so task is disabled
- const disabled = PermissionNext.disabled(["task"], ruleset)
+ const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
},
})
@@ -301,17 +305,17 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
- const ruleset = PermissionNext.fromConfig(config.permission ?? {})
+ const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny
- expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
+ expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
// Other agents still denied by the earlier "*" deny
- expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
+ expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
// So the task tool is NOT disabled (even though most subagents are denied)
- const disabled = PermissionNext.disabled(["task"], ruleset)
+ const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(false)
},
})
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 2a6b6e0ba..043e3257b 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -1,11 +1,7 @@
import { afterEach, test, expect } from "bun:test"
import os from "os"
-import { Effect } from "effect"
import { Bus } from "../../src/bus"
-import { runtime } from "../../src/effect/runtime"
-import { Instances } from "../../src/effect/instances"
-import { PermissionNext } from "../../src/permission"
-import { PermissionNext as S } from "../../src/permission"
+import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -16,8 +12,8 @@ afterEach(async () => {
})
async function rejectAll(message?: string) {
- for (const req of await PermissionNext.list()) {
- await PermissionNext.reply({
+ for (const req of await Permission.list()) {
+ await Permission.reply({
requestID: req.id,
reply: "reject",
message,
@@ -27,22 +23,22 @@ async function rejectAll(message?: string) {
async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) {
- const list = await PermissionNext.list()
+ const list = await Permission.list()
if (list.length === count) return list
await Bun.sleep(0)
}
- return PermissionNext.list()
+ return Permission.list()
}
// fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => {
- const result = PermissionNext.fromConfig({ bash: "allow" })
+ const result = Permission.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("fromConfig - object value converts to rules array", () => {
- const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
+ const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
@@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => {
})
test("fromConfig - mixed string and object values", () => {
- const result = PermissionNext.fromConfig({
+ const result = Permission.fromConfig({
bash: { "*": "allow", rm: "deny" },
edit: "allow",
webfetch: "ask",
@@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => {
})
test("fromConfig - empty object", () => {
- const result = PermissionNext.fromConfig({})
+ const result = Permission.fromConfig({})
expect(result).toEqual([])
})
test("fromConfig - expands tilde to home directory", () => {
- const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
+ const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME to home directory", () => {
- const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
+ const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME without trailing slash", () => {
- const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
+ const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("fromConfig - does not expand tilde in middle of path", () => {
- const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
+ const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
test("fromConfig - expands exact tilde to home directory", () => {
- const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
+ const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("evaluate - matches expanded tilde pattern", () => {
- const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
- const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
+ const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
+ const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
test("evaluate - matches expanded $HOME pattern", () => {
- const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
- const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
+ const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
+ const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
// merge tests
test("merge - simple concatenation", () => {
- const result = PermissionNext.merge(
+ const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
@@ -119,7 +115,7 @@ test("merge - simple concatenation", () => {
})
test("merge - adds new permission", () => {
- const result = PermissionNext.merge(
+ const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "edit", pattern: "*", action: "deny" }],
)
@@ -130,7 +126,7 @@ test("merge - adds new permission", () => {
})
test("merge - concatenates rules for same permission", () => {
- const result = PermissionNext.merge(
+ const result = Permission.merge(
[{ permission: "bash", pattern: "foo", action: "ask" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
@@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => {
})
test("merge - multiple rulesets", () => {
- const result = PermissionNext.merge(
+ const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "rm", action: "ask" }],
[{ permission: "edit", pattern: "*", action: "allow" }],
@@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => {
})
test("merge - empty ruleset does nothing", () => {
- const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
+ const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("merge - preserves rule order", () => {
- const result = PermissionNext.merge(
+ const result = Permission.merge(
[
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
@@ -175,40 +171,40 @@ test("merge - preserves rule order", () => {
test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow"
- const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
- const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
- const merged = PermissionNext.merge(defaults, config)
+ const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
+ const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+ const merged = Permission.merge(defaults, config)
// Config's bash allow should override default ask
- expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
+ expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
// Other permissions should still be ask (from defaults)
- expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
+ expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask")
})
test("merge - config ask overrides default allow", () => {
// Simulates: defaults have bash: "allow", config sets bash: "ask"
- const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
- const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
- const merged = PermissionNext.merge(defaults, config)
+ const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+ const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
+ const merged = Permission.merge(defaults, config)
// Config's ask should override default allow
- expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
+ expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
})
// evaluate tests
test("evaluate - exact pattern match", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
+ const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
+ const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching rule wins", () => {
- const result = PermissionNext.evaluate("bash", "rm", [
+ const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
])
@@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => {
})
test("evaluate - last matching rule wins (wildcard after specific)", () => {
- const result = PermissionNext.evaluate("bash", "rm", [
+ const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
})
test("evaluate - glob pattern match", () => {
- const result = PermissionNext.evaluate("edit", "src/foo.ts", [
- { permission: "edit", pattern: "src/*", action: "allow" },
- ])
+ const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching glob wins", () => {
- const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+ const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" },
])
@@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => {
test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it
- const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
+ const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" },
])
@@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => {
})
test("evaluate - unknown permission returns ask", () => {
- const result = PermissionNext.evaluate("unknown_tool", "anything", [
+ const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("ask")
})
test("evaluate - empty ruleset returns ask", () => {
- const result = PermissionNext.evaluate("bash", "rm", [])
+ const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - no matching pattern returns ask", () => {
- const result = PermissionNext.evaluate("edit", "etc/passwd", [
- { permission: "edit", pattern: "src/*", action: "allow" },
- ])
+ const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
expect(result.action).toBe("ask")
})
test("evaluate - empty rules array returns ask", () => {
- const result = PermissionNext.evaluate("bash", "rm", [])
+ const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - multiple matching patterns, last wins", () => {
- const result = PermissionNext.evaluate("edit", "src/secret.ts", [
+ const result = Permission.evaluate("edit", "src/secret.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
@@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
})
test("evaluate - non-matching patterns are skipped", () => {
- const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+ const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
@@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => {
})
test("evaluate - exact match at end wins over earlier wildcard", () => {
- const result = PermissionNext.evaluate("bash", "/bin/rm", [
+ const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
])
@@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
})
test("evaluate - wildcard at end overrides earlier exact match", () => {
- const result = PermissionNext.evaluate("bash", "/bin/rm", [
+ const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
// wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
+ const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard permission with specific pattern", () => {
- const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
+ const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - glob permission pattern", () => {
- const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
+ const result = Permission.evaluate("mcp_server_tool", "anything", [
{ permission: "mcp_*", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - specific permission and wildcard permission combined", () => {
- const result = PermissionNext.evaluate("bash", "rm", [
+ const result = Permission.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
})
test("evaluate - wildcard permission does not match when specific exists", () => {
- const result = PermissionNext.evaluate("edit", "src/foo.ts", [
+ const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
@@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
})
test("evaluate - multiple matching permission patterns combine rules", () => {
- const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
+ const result = Permission.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
@@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
})
test("evaluate - wildcard permission fallback for unknown tool", () => {
- const result = PermissionNext.evaluate("unknown_tool", "anything", [
+ const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -359,7 +351,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win
- const result = PermissionNext.evaluate("bash", "rm", [
+ const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
])
@@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
})
test("evaluate - merges multiple rulesets", () => {
- const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
- const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
+ const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+ const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
// approved comes after config, so rm should be denied
- const result = PermissionNext.evaluate("bash", "rm", config, approved)
+ const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
// disabled tests
test("disabled - returns empty set when all tools allowed", () => {
- const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
+ const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0)
})
test("disabled - disables tool when denied", () => {
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "allow" },
@@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => {
})
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["edit", "write", "apply_patch", "multiedit", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
@@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
})
test("disabled - does not disable when partially denied", () => {
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "allow" },
@@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => {
})
test("disabled - does not disable when action is ask", () => {
- const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
+ const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0)
})
test("disabled - does not disable when specific allow after wildcard deny", () => {
// Tool is NOT disabled because a specific allow after wildcard deny means
// there's at least some usage allowed
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "deny" },
@@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
})
test("disabled - does not disable when wildcard allow after deny", () => {
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "rm *", action: "deny" },
@@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
})
test("disabled - disables multiple tools", () => {
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["bash", "edit", "webfetch"],
[
{ permission: "bash", pattern: "*", action: "deny" },
@@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
})
test("disabled - wildcard permission denies all tools", () => {
- const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
+ const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})
test("disabled - specific allow overrides wildcard deny", () => {
- const result = PermissionNext.disabled(
+ const result = Permission.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "deny" },
@@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const result = await PermissionNext.ask({
+ const result = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => {
directory: tmp.path,
fn: async () => {
await expect(
- PermissionNext.ask({
+ Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
@@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
- ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
+ ).rejects.toBeInstanceOf(Permission.DeniedError)
},
})
})
@@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const promise = PermissionNext.ask({
+ const promise = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const ask = PermissionNext.ask({
+ const ask = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => {
ruleset: [],
})
- const list = await PermissionNext.list()
+ const list = await Permission.list()
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
@@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- let seen: PermissionNext.Request | undefined
- const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
+ let seen: Permission.Request | undefined
+ const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
seen = event.properties
})
- const ask = PermissionNext.ask({
+ const ask = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => {
ruleset: [],
})
- expect(await PermissionNext.list()).toHaveLength(1)
+ expect(await Permission.list()).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
@@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const askPromise = PermissionNext.ask({
+ const askPromise = Permission.ask({
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => {
await waitForPending(1)
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test1"),
reply: "once",
})
@@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const askPromise = PermissionNext.ask({
+ const askPromise = Permission.ask({
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => {
await waitForPending(1)
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test2"),
reply: "reject",
})
- await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
+ await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
},
})
})
@@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const ask = PermissionNext.ask({
+ const ask = Permission.ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => {
await waitForPending(1)
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
const err = await ask.catch((err) => err)
- expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
+ expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err.message).toContain("Use a safer command")
},
})
@@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const askPromise = PermissionNext.ask({
+ const askPromise = Permission.ask({
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => {
await waitForPending(1)
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test3"),
reply: "always",
})
@@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => {
directory: tmp.path,
fn: async () => {
// Stored approval should allow without asking
- const result = await PermissionNext.ask({
+ const result = await Permission.ask({
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
@@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const askPromise1 = PermissionNext.ask({
+ const askPromise1 = Permission.ask({
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [],
})
- const askPromise2 = PermissionNext.ask({
+ const askPromise2 = Permission.ask({
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
@@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => {
const result2 = askPromise2.catch((e) => e)
// Reject the first one
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test4a"),
reply: "reject",
})
// Both should be rejected
- expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
- expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
+ expect(await result1).toBeInstanceOf(Permission.RejectedError)
+ expect(await result2).toBeInstanceOf(Permission.RejectedError)
},
})
})
@@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const a = PermissionNext.ask({
+ const a = Permission.ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async
ruleset: [],
})
- const b = PermissionNext.ask({
+ const b = Permission.ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async
await waitForPending(2)
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test5a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined()
- expect(await PermissionNext.list()).toHaveLength(0)
+ expect(await Permission.list()).toHaveLength(0)
},
})
})
@@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const a = PermissionNext.ask({
+ const a = Permission.ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
@@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => {
ruleset: [],
})
- const b = PermissionNext.ask({
+ const b = Permission.ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
@@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => {
await waitForPending(2)
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test6a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
- expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
+ expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
await rejectAll()
await b.catch(() => {})
@@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const ask = PermissionNext.ask({
+ const ask = Permission.ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => {
| {
sessionID: SessionID
requestID: PermissionID
- reply: PermissionNext.Reply
+ reply: Permission.Reply
}
| undefined
- const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
+ const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
seen = event.properties
})
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_test7"),
reply: "once",
})
@@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => {
})
})
+test("permission requests stay isolated by directory", async () => {
+ await using one = await tmpdir({ git: true })
+ await using two = await tmpdir({ git: true })
+
+ const a = Instance.provide({
+ directory: one.path,
+ fn: () =>
+ Permission.ask({
+ id: PermissionID.make("per_dir_a"),
+ sessionID: SessionID.make("session_dir_a"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }),
+ })
+
+ const b = Instance.provide({
+ directory: two.path,
+ fn: () =>
+ Permission.ask({
+ id: PermissionID.make("per_dir_b"),
+ sessionID: SessionID.make("session_dir_b"),
+ permission: "bash",
+ patterns: ["pwd"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }),
+ })
+
+ const onePending = await Instance.provide({
+ directory: one.path,
+ fn: () => waitForPending(1),
+ })
+ const twoPending = await Instance.provide({
+ directory: two.path,
+ fn: () => waitForPending(1),
+ })
+
+ expect(onePending).toHaveLength(1)
+ expect(twoPending).toHaveLength(1)
+ expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
+ expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
+
+ await Instance.provide({
+ directory: one.path,
+ fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }),
+ })
+ await Instance.provide({
+ directory: two.path,
+ fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }),
+ })
+
+ await a.catch(() => {})
+ await b.catch(() => {})
+})
+
+test("pending permission rejects on instance dispose", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const ask = Instance.provide({
+ directory: tmp.path,
+ fn: () =>
+ Permission.ask({
+ id: PermissionID.make("per_dispose"),
+ sessionID: SessionID.make("session_dispose"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }),
+ })
+ const result = ask.then(
+ () => "resolved" as const,
+ (err) => err,
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const pending = await waitForPending(1)
+ expect(pending).toHaveLength(1)
+ await Instance.dispose()
+ },
+ })
+
+ expect(await result).toBeInstanceOf(Permission.RejectedError)
+})
+
+test("pending permission rejects on instance reload", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const ask = Instance.provide({
+ directory: tmp.path,
+ fn: () =>
+ Permission.ask({
+ id: PermissionID.make("per_reload"),
+ sessionID: SessionID.make("session_reload"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }),
+ })
+ const result = ask.then(
+ () => "resolved" as const,
+ (err) => err,
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const pending = await waitForPending(1)
+ expect(pending).toHaveLength(1)
+ await Instance.reload({ directory: tmp.path })
+ },
+ })
+
+ expect(await result).toBeInstanceOf(Permission.RejectedError)
+})
+
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await PermissionNext.reply({
+ await Permission.reply({
requestID: PermissionID.make("per_unknown"),
reply: "once",
})
- expect(await PermissionNext.list()).toHaveLength(0)
+ expect(await Permission.list()).toHaveLength(0)
},
})
})
@@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
directory: tmp.path,
fn: async () => {
await expect(
- PermissionNext.ask({
+ Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
- ).rejects.toBeInstanceOf(PermissionNext.DeniedError)
+ ).rejects.toBeInstanceOf(Permission.DeniedError)
},
})
})
@@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const result = await PermissionNext.ask({
+ const result = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
@@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- const err = await PermissionNext.ask({
+ const err = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
(err) => err,
)
- expect(err).toBeInstanceOf(PermissionNext.DeniedError)
- expect(await PermissionNext.list()).toHaveLength(0)
+ expect(err).toBeInstanceOf(Permission.DeniedError)
+ expect(await Permission.list()).toHaveLength(0)
},
})
})
@@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => {
directory: tmp.path,
fn: async () => {
const ctl = new AbortController()
- const ask = runtime.runPromise(
- S.Service.use((svc) =>
+ const ask = Permission.runPromise(
+ (svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}),
- ).pipe(Effect.provide(Instances.get(Instance.directory))),
{ signal: ctl.signal },
)
@@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => {
await ask.catch(() => {})
try {
- expect(await PermissionNext.list()).toHaveLength(0)
+ expect(await Permission.list()).toHaveLength(0)
} finally {
await rejectAll()
}
diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts
index 0095ff387..b96726225 100644
--- a/packages/opencode/test/plugin/auth-override.test.ts
+++ b/packages/opencode/test/plugin/auth-override.test.ts
@@ -31,15 +31,26 @@ describe("plugin.auth-override", () => {
},
})
- await Instance.provide({
+ await using plain = await tmpdir()
+
+ const methods = await Instance.provide({
directory: tmp.path,
fn: async () => {
- const methods = await ProviderAuth.methods()
- const copilot = methods[ProviderID.make("github-copilot")]
- expect(copilot).toBeDefined()
- expect(copilot.length).toBe(1)
- expect(copilot[0].label).toBe("Test Override Auth")
+ return ProviderAuth.methods()
+ },
+ })
+
+ const plainMethods = await Instance.provide({
+ directory: plain.path,
+ fn: async () => {
+ return ProviderAuth.methods()
},
})
+
+ const copilot = methods[ProviderID.make("github-copilot")]
+ expect(copilot).toBeDefined()
+ expect(copilot.length).toBe(1)
+ expect(copilot[0].label).toBe("Test Override Auth")
+ expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation
})
diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts
index 90f445ed7..11463b795 100644
--- a/packages/opencode/test/project/vcs.test.ts
+++ b/packages/opencode/test/project/vcs.test.ts
@@ -25,8 +25,8 @@ function withVcs(
directory,
Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => {
- await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
- await rt.runPromise(Vcs.Service.use(() => Effect.void))
+ await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
+ await rt.runPromise(Vcs.Service.use((s) => s.init()))
await Bun.sleep(500)
await body(rt)
},
@@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
// ---------------------------------------------------------------------------
describeVcs("Vcs", () => {
- afterEach(() => Instance.disposeAll())
+ afterEach(async () => {
+ await Instance.disposeAll()
+ })
test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true })
diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts
index 45e0d3c31..adfeda395 100644
--- a/packages/opencode/test/question/question.test.ts
+++ b/packages/opencode/test/question/question.test.ts
@@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => {
},
})
})
+
+test("questions stay isolated by directory", async () => {
+ await using one = await tmpdir({ git: true })
+ await using two = await tmpdir({ git: true })
+
+ const p1 = Instance.provide({
+ directory: one.path,
+ fn: () =>
+ Question.ask({
+ sessionID: SessionID.make("ses_one"),
+ questions: [
+ {
+ question: "Question 1?",
+ header: "Q1",
+ options: [{ label: "A", description: "A" }],
+ },
+ ],
+ }),
+ })
+
+ const p2 = Instance.provide({
+ directory: two.path,
+ fn: () =>
+ Question.ask({
+ sessionID: SessionID.make("ses_two"),
+ questions: [
+ {
+ question: "Question 2?",
+ header: "Q2",
+ options: [{ label: "B", description: "B" }],
+ },
+ ],
+ }),
+ })
+
+ const onePending = await Instance.provide({
+ directory: one.path,
+ fn: () => Question.list(),
+ })
+ const twoPending = await Instance.provide({
+ directory: two.path,
+ fn: () => Question.list(),
+ })
+
+ expect(onePending.length).toBe(1)
+ expect(twoPending.length).toBe(1)
+ expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
+ expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
+
+ await Instance.provide({
+ directory: one.path,
+ fn: () => Question.reject(onePending[0].id),
+ })
+ await Instance.provide({
+ directory: two.path,
+ fn: () => Question.reject(twoPending[0].id),
+ })
+
+ await p1.catch(() => {})
+ await p2.catch(() => {})
+})
+
+test("pending question rejects on instance dispose", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const ask = Instance.provide({
+ directory: tmp.path,
+ fn: () => {
+ return Question.ask({
+ sessionID: SessionID.make("ses_dispose"),
+ questions: [
+ {
+ question: "Dispose me?",
+ header: "Dispose",
+ options: [{ label: "Yes", description: "Yes" }],
+ },
+ ],
+ })
+ },
+ })
+ const result = ask.then(
+ () => "resolved" as const,
+ (err) => err,
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const pending = await Question.list()
+ expect(pending).toHaveLength(1)
+ await Instance.dispose()
+ },
+ })
+
+ expect(await result).toBeInstanceOf(Question.RejectedError)
+})
+
+test("pending question rejects on instance reload", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const ask = Instance.provide({
+ directory: tmp.path,
+ fn: () => {
+ return Question.ask({
+ sessionID: SessionID.make("ses_reload"),
+ questions: [
+ {
+ question: "Reload me?",
+ header: "Reload",
+ options: [{ label: "Yes", description: "Yes" }],
+ },
+ ],
+ })
+ },
+ })
+ const result = ask.then(
+ () => "resolved" as const,
+ (err) => err,
+ )
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const pending = await Question.list()
+ expect(pending).toHaveLength(1)
+ await Instance.reload({ directory: tmp.path })
+ },
+ })
+
+ expect(await result).toBeInstanceOf(Question.RejectedError)
+})
diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts
index 5be5d0245..fc8d51150 100644
--- a/packages/opencode/test/share/share-next.test.ts
+++ b/packages/opencode/test/share/share-next.test.ts
@@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async
const originalActive = Account.active
const originalConfigGet = Config.get
- Account.active = mock(() => undefined)
+ Account.active = mock(async () => undefined)
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
try {
@@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act
const originalActive = Account.active
const originalToken = Account.token
- Account.active = mock(() => ({
+ Account.active = mock(async () => ({
id: AccountID.make("account-1"),
url: "https://control.example.com",
@@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => {
const originalActive = Account.active
const originalToken = Account.token
- Account.active = mock(() => ({
+ Account.active = mock(async () => ({
id: AccountID.make("account-1"),
url: "https://control.example.com",
diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts
index 2264723a0..12e16f86a 100644
--- a/packages/opencode/test/skill/skill.test.ts
+++ b/packages/opencode/test/skill/skill.test.ts
@@ -1,10 +1,14 @@
-import { test, expect } from "bun:test"
+import { afterEach, test, expect } from "bun:test"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
await fs.mkdir(skillDir, { recursive: true })
diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts
index 203050287..bf54feb47 100644
--- a/packages/opencode/test/snapshot/snapshot.test.ts
+++ b/packages/opencode/test/snapshot/snapshot.test.ts
@@ -1,4 +1,4 @@
-import { test, expect } from "bun:test"
+import { afterEach, test, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
@@ -12,6 +12,10 @@ import { tmpdir } from "../fixture/fixture"
// This helper does the same for expected values so assertions match cross-platform.
const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
async function bootstrap() {
return tmpdir({
git: true,
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index a5c7cec91..4d680d494 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -5,7 +5,7 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
-import type { PermissionNext } from "../../src/permission"
+import type { Permission } from "../../src/permission"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -49,10 +49,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -76,10 +76,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -104,10 +104,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -130,10 +130,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -163,10 +163,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -193,10 +193,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -223,10 +223,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -250,10 +250,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -276,10 +276,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -297,10 +297,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
index 7b6784cf4..f6b1ee5c9 100644
--- a/packages/opencode/test/tool/edit.test.ts
+++ b/packages/opencode/test/tool/edit.test.ts
@@ -1,4 +1,4 @@
-import { describe, test, expect } from "bun:test"
+import { afterEach, describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { EditTool } from "../../src/tool/edit"
@@ -18,6 +18,10 @@ const ctx = {
ask: async () => {},
}
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts
index 229901a72..0188cbada 100644
--- a/packages/opencode/test/tool/external-directory.test.ts
+++ b/packages/opencode/test/tool/external-directory.test.ts
@@ -3,7 +3,7 @@ import path from "path"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
-import type { PermissionNext } from "../../src/permission"
+import type { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
@@ -18,7 +18,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
describe("tool.assertExternalDirectory", () => {
test("no-ops for empty target", async () => {
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -37,7 +37,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("no-ops for paths inside Instance.directory", async () => {
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -56,7 +56,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("asks with a single canonical glob", async () => {
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -82,7 +82,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("uses target directory when kind=directory", async () => {
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -108,7 +108,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("skips prompting when bypass=true", async () => {
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index cfeb597fc..06a7f9a70 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -1,15 +1,19 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
-import { PermissionNext } from "../../src/permission"
+import { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
@@ -65,10 +69,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -91,10 +95,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -112,10 +116,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -138,10 +142,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -176,14 +180,14 @@ describe("tool.read env file permissions", () => {
let askedForEnv = false
const ctxWithPermissions = {
...ctx,
- ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+ ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
- const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
+ const rule = Permission.evaluate(req.permission, pattern, agent.permission)
if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true
}
if (rule.action === "deny") {
- throw new PermissionNext.DeniedError({ ruleset: agent.permission })
+ throw new Permission.DeniedError({ ruleset: agent.permission })
}
}
},
diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts
index 706a9e12c..c9951ef19 100644
--- a/packages/opencode/test/tool/registry.test.ts
+++ b/packages/opencode/test/tool/registry.test.ts
@@ -1,10 +1,14 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
describe("tool.registry", () => {
test("loads tools from .opencode/tool (singular)", async () => {
await using tmp = await tmpdir({
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index f622341d3..ffae223f9 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -1,7 +1,7 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
-import type { PermissionNext } from "../../src/permission"
+import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
@@ -18,6 +18,10 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
metadata: () => {},
}
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
describe("tool.skill", () => {
test("description lists skill location URL", async () => {
await using tmp = await tmpdir({
@@ -133,7 +137,7 @@ Use this skill.
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
- const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+ const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
index df319d8de..aae48a30a 100644
--- a/packages/opencode/test/tool/task.test.ts
+++ b/packages/opencode/test/tool/task.test.ts
@@ -1,9 +1,13 @@
-import { describe, expect, test } from "bun:test"
+import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
describe("tool.task", () => {
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({
diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts
index 032f0bfee..dba083c51 100644
--- a/packages/opencode/test/tool/truncation.test.ts
+++ b/packages/opencode/test/tool/truncation.test.ts
@@ -1,8 +1,7 @@
import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
-import { Truncate } from "../../src/tool/truncate"
-import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
+import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
@@ -129,7 +128,7 @@ describe("Truncate", () => {
})
test("loads truncate effect in a fresh process", async () => {
- const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
+ const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
cwd: ROOT,
})
diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts
index af002a391..97939c105 100644
--- a/packages/opencode/test/tool/write.test.ts
+++ b/packages/opencode/test/tool/write.test.ts
@@ -1,4 +1,4 @@
-import { describe, test, expect } from "bun:test"
+import { afterEach, describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
@@ -17,6 +17,10 @@ const ctx = {
ask: async () => {},
}
+afterEach(async () => {
+ await Instance.disposeAll()
+})
+
describe("tool.write", () => {
describe("new file creation", () => {
test("writes content to new file", async () => {