summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-18 13:34:36 -0400
committerGitHub <[email protected]>2026-03-18 13:34:36 -0400
commita800583aeaf6500d7bcd09e69ea6e8c8225600a1 (patch)
treeb71c05c3ba02377ff1035b38ab594d0d16048542 /packages
parent171e69c2fc148985af7b9506b47f048d3a34a767 (diff)
downloadopencode-a800583aeaf6500d7bcd09e69ea6e8c8225600a1.tar.gz
opencode-a800583aeaf6500d7bcd09e69ea6e8c8225600a1.zip
refactor(effect): unify service namespaces and align naming (#18093)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/AGENTS.md76
-rw-r--r--packages/opencode/specs/effect-migration.md144
-rw-r--r--packages/opencode/src/account/effect.ts (renamed from packages/opencode/src/account/service.ts)33
-rw-r--r--packages/opencode/src/account/index.ts14
-rw-r--r--packages/opencode/src/auth/effect.ts (renamed from packages/opencode/src/auth/service.ts)40
-rw-r--r--packages/opencode/src/auth/index.ts8
-rw-r--r--packages/opencode/src/cli/cmd/account.ts10
-rw-r--r--packages/opencode/src/effect/instances.ts64
-rw-r--r--packages/opencode/src/effect/runtime.ts8
-rw-r--r--packages/opencode/src/file/index.ts811
-rw-r--r--packages/opencode/src/file/time.ts157
-rw-r--r--packages/opencode/src/file/watcher.ts118
-rw-r--r--packages/opencode/src/format/index.ts146
-rw-r--r--packages/opencode/src/permission/index.ts277
-rw-r--r--packages/opencode/src/permission/service.ts244
-rw-r--r--packages/opencode/src/project/bootstrap.ts9
-rw-r--r--packages/opencode/src/project/vcs.ts71
-rw-r--r--packages/opencode/src/provider/auth-service.ts230
-rw-r--r--packages/opencode/src/provider/auth.ts231
-rw-r--r--packages/opencode/src/question/index.ts192
-rw-r--r--packages/opencode/src/question/service.ts172
-rw-r--r--packages/opencode/src/server/server.ts4
-rw-r--r--packages/opencode/src/skill/discovery.ts195
-rw-r--r--packages/opencode/src/skill/skill.ts402
-rw-r--r--packages/opencode/src/snapshot/index.ts299
-rw-r--r--packages/opencode/src/tool/truncate-effect.ts12
-rw-r--r--packages/opencode/test/account/service.test.ts14
-rw-r--r--packages/opencode/test/file/watcher.test.ts9
-rw-r--r--packages/opencode/test/format/format.test.ts23
-rw-r--r--packages/opencode/test/permission/next.test.ts4
-rw-r--r--packages/opencode/test/plugin/auth-override.test.ts3
-rw-r--r--packages/opencode/test/project/vcs.test.ts32
-rw-r--r--packages/opencode/test/server/project-init-git.test.ts3
-rw-r--r--packages/opencode/test/skill/discovery.test.ts4
-rw-r--r--packages/opencode/test/snapshot/snapshot.test.ts34
35 files changed, 2034 insertions, 2059 deletions
diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md
index f28150622..e2a0c918d 100644
--- a/packages/opencode/AGENTS.md
+++ b/packages/opencode/AGENTS.md
@@ -9,71 +9,55 @@
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
-# opencode Effect guide
+# opencode Effect rules
-Instructions to follow when writing Effect.
+Use these rules when writing or migrating Effect code.
-## Schemas
+See `specs/effect-migration.md` for the compact pattern reference and examples.
-- Use `Schema.Class` for data types with multiple fields.
-- Use branded schemas (`Schema.brand`) for single-value types.
-
-## Services
-
-- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
-- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
-
-## Errors
-
-- Use `Schema.TaggedErrorClass` for typed errors.
-- For defect-like causes, use `Schema.Defect` instead of `unknown`.
-- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
-
-## Effects
+## Core
- Use `Effect.gen(function* () { ... })` for composition.
-- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
-- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
-- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
-
-## Time
-
+- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
+- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
+- Use `Effect.callback` for callback-based APIs.
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
-## Errors
+## Schemas and errors
+
+- Use `Schema.Class` for multi-field data.
+- Use branded schemas (`Schema.brand`) for single-value types.
+- Use `Schema.TaggedErrorClass` for typed errors.
+- Use `Schema.Defect` instead of `unknown` for defect-like causes.
+- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
-- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
+## Runtime vs Instances
-## Instance-scoped Effect services
+- Use the shared runtime for process-wide services with one lifecycle for the whole app.
+- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
+- If two open directories should not share one copy of the service, it belongs in `Instances`.
+- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
-Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
+## Preferred Effect services
-1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
-2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
-3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
-4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
+- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
+- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
+- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
+- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
+- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
+- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
-### Instance.bind — ALS context for native callbacks
+## Instance.bind — ALS for native callbacks
-`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
+`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
-**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
+Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
-**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
+You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
```typescript
-// Native addon callback — needs Instance.bind
const cb = Instance.bind((err, evts) => {
Bus.publish(MyEvent, { ... })
})
nativeAddon.subscribe(dir, cb)
```
-
-## Flag → Effect.Config migration
-
-Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
-
-- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
-- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
-- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
-- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.
diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md
new file mode 100644
index 000000000..4f195917f
--- /dev/null
+++ b/packages/opencode/specs/effect-migration.md
@@ -0,0 +1,144 @@
+# Effect patterns
+
+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 `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
+
+- Shared runtime: config readers, stateless helpers, global clients
+- Instance-scoped: watchers, per-project caches, session state, project-bound background work
+
+Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
+
+## Service shape
+
+For a fully migrated module, use the public namespace directly:
+
+```ts
+export namespace Foo {
+ export interface Interface {
+ readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ return Service.of({
+ get: Effect.fn("Foo.get")(function* (id) {
+ return yield* ...
+ }),
+ })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
+}
+```
+
+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
+
+Prefer a single namespace whenever possible.
+
+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.
+
+```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") {}
+
+ 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)))
+ }
+}
+```
+
+Remove the `Effect` suffix when the boundary split is gone.
+
+## Scheduled Tasks
+
+For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
+
+## Preferred Effect services
+
+In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
+
+Prefer these first:
+
+- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O
+- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers
+- `HttpClient.HttpClient` instead of raw `fetch`
+- `Path.Path` instead of mixing path helpers into service code when you already need a path service
+- `Config` for effect-native configuration reads
+- `Clock` / `DateTime` for time reads inside effects
+
+## Child processes
+
+For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`.
+
+Keep shelling-out code inside the service, not in callers.
+
+## Shared leaf models
+
+Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
+
+That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace.
+
+## 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`
+
+Still open and likely worth migrating:
+
+- [ ] `Plugin`
+- [ ] `ToolRegistry`
+- [ ] `Pty`
+- [ ] `Worktree`
+- [ ] `Installation`
+- [ ] `Bus`
+- [ ] `Command`
+- [ ] `Config`
+- [ ] `Session`
+- [ ] `SessionProcessor`
+- [ ] `SessionPrompt`
+- [ ] `SessionCompaction`
+- [ ] `Provider`
+- [ ] `Project`
+- [ ] `LSP`
+- [ ] `MCP`
diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/effect.ts
index 87e95c8f4..444676046 100644
--- a/packages/opencode/src/account/service.ts
+++ b/packages/opencode/src/account/effect.ts
@@ -108,8 +108,8 @@ const mapAccountServiceError =
),
)
-export namespace AccountService {
- export interface Service {
+export namespace AccountEffect {
+ export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
@@ -124,11 +124,11 @@ export namespace AccountService {
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
-}
-export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
- static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
- AccountService,
+ 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
@@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
mapAccountServiceError("HTTP request failed"),
)
- // Returns a usable access token for a stored account row, refreshing and
- // persisting it when the cached token has expired.
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
)
})
- const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
+ const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
- const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
+ const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
@@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
return results
})
- const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
+ const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
@@ -246,7 +244,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
return yield* fetchOrgs(account.url, accessToken)
})
- const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
+ const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
@@ -270,7 +268,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
return Option.some(parsed.config)
})
- const login = Effect.fn("AccountService.login")(function* (server: string) {
+ const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
@@ -291,7 +289,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
})
})
- const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
+ const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
@@ -337,7 +335,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
return new PollSuccess({ email: account.email })
})
- return AccountService.of({
+ return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
@@ -352,8 +350,5 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
}),
)
- static readonly defaultLayer = AccountService.layer.pipe(
- Layer.provide(AccountRepo.layer),
- Layer.provide(FetchHttpClient.layer),
- )
+ 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 ed4c3d879..3a9d758e2 100644
--- a/packages/opencode/src/account/index.ts
+++ b/packages/opencode/src/account/index.ts
@@ -5,20 +5,20 @@ import {
type AccountError,
type AccessToken,
AccountID,
- AccountService,
+ AccountEffect,
OrgID,
-} from "./service"
+} from "./effect"
-export { AccessToken, AccountID, OrgID } from "./service"
+export { AccessToken, AccountID, OrgID } from "./effect"
import { runtime } from "@/effect/runtime"
-function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
- return runtime.runSync(AccountService.use(f))
+function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
+ return runtime.runSync(AccountEffect.Service.use(f))
}
-function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
- return runtime.runPromise(AccountService.use(f))
+function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
+ return runtime.runPromise(AccountEffect.Service.use(f))
}
export namespace Account {
diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/effect.ts
index 100a132b8..043b9002e 100644
--- a/packages/opencode/src/auth/service.ts
+++ b/packages/opencode/src/auth/effect.ts
@@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
-export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
+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 AuthServiceError({ message, cause })
+const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
-export namespace AuthService {
- export interface Service {
- readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
- readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
- readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
- readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
+export namespace AuthEffect {
+ 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 AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
- static readonly layer = Layer.effect(
- AuthService,
+ 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("AuthService.all")(() =>
+ const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
@@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
}),
)
- const get = Effect.fn("AuthService.get")(function* (providerID: string) {
+ const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
- const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
+ 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]
@@ -77,7 +77,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
})
})
- const remove = Effect.fn("AuthService.remove")(function* (key: string) {
+ const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
@@ -88,14 +88,8 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
})
})
- return AuthService.of({
- get,
- all,
- set,
- remove,
- })
+ return Service.of({ get, all, set, remove })
}),
)
- static readonly defaultLayer = AuthService.layer
}
diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts
index 79e9e615d..6f588e937 100644
--- a/packages/opencode/src/auth/index.ts
+++ b/packages/opencode/src/auth/index.ts
@@ -1,12 +1,12 @@
import { Effect } from "effect"
import z from "zod"
import { runtime } from "@/effect/runtime"
-import * as S from "./service"
+import * as S from "./effect"
-export { OAUTH_DUMMY_KEY } from "./service"
+export { OAUTH_DUMMY_KEY } from "./effect"
-function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
- return runtime.runPromise(S.AuthService.use(f))
+function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
+ return runtime.runPromise(S.AuthEffect.Service.use(f))
}
export namespace Auth {
diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts
index b2256837d..c2b47da11 100644
--- a/packages/opencode/src/cli/cmd/account.ts
+++ b/packages/opencode/src/cli/cmd/account.ts
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { runtime } from "@/effect/runtime"
-import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
+import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
- const service = yield* AccountService
+ const service = yield* AccountEffect.Service
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
- const service = yield* AccountService
+ const service = yield* AccountEffect.Service
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in")
@@ -98,7 +98,7 @@ interface OrgChoice {
}
const switchEffect = Effect.fn("switch")(function* () {
- const service = yield* AccountService
+ const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
})
const orgsEffect = Effect.fn("orgs")(function* () {
- const service = yield* AccountService
+ const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found")
diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts
index 3a1fb0cdf..c05458d5d 100644
--- a/packages/opencode/src/effect/instances.ts
+++ b/packages/opencode/src/effect/instances.ts
@@ -1,31 +1,31 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
-import { FileService } from "@/file"
-import { FileTimeService } from "@/file/time"
-import { FileWatcherService } from "@/file/watcher"
-import { FormatService } from "@/format"
-import { PermissionEffect } from "@/permission/service"
+import { File } from "@/file"
+import { FileTime } from "@/file/time"
+import { FileWatcher } from "@/file/watcher"
+import { Format } from "@/format"
+import { PermissionNext } from "@/permission"
import { Instance } from "@/project/instance"
-import { VcsService } from "@/project/vcs"
-import { ProviderAuthService } from "@/provider/auth-service"
-import { QuestionService } from "@/question/service"
-import { SkillService } from "@/skill/skill"
-import { SnapshotService } from "@/snapshot"
+import { Vcs } from "@/project/vcs"
+import { ProviderAuth } from "@/provider/auth"
+import { Question } from "@/question"
+import { Skill } from "@/skill/skill"
+import { Snapshot } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context"
export type InstanceServices =
- | QuestionService
- | PermissionEffect.Service
- | ProviderAuthService
- | FileWatcherService
- | VcsService
- | FileTimeService
- | FormatService
- | FileService
- | SkillService
- | SnapshotService
+ | Question.Service
+ | PermissionNext.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
@@ -36,16 +36,16 @@ export type InstanceServices =
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll(
- Layer.fresh(QuestionService.layer),
- Layer.fresh(PermissionEffect.layer),
- Layer.fresh(ProviderAuthService.layer),
- Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
- Layer.fresh(VcsService.layer),
- Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
- Layer.fresh(FormatService.layer),
- Layer.fresh(FileService.layer),
- Layer.fresh(SkillService.layer),
- Layer.fresh(SnapshotService.layer),
+ Layer.fresh(Question.layer),
+ Layer.fresh(PermissionNext.layer),
+ Layer.fresh(ProviderAuth.defaultLayer),
+ Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
+ Layer.fresh(Vcs.layer),
+ Layer.fresh(FileTime.layer).pipe(Layer.orDie),
+ Layer.fresh(Format.layer),
+ Layer.fresh(File.layer),
+ Layer.fresh(Skill.defaultLayer),
+ Layer.fresh(Snapshot.defaultLayer),
).pipe(Layer.provide(ctx))
}
@@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static readonly layer = Layer.effect(
Instances,
Effect.gen(function* () {
- const layerMap = yield* LayerMap.make(lookup, {
- idleTimeToLive: Infinity,
- })
+ 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)
diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
index a55956bfd..f52203b22 100644
--- a/packages/opencode/src/effect/runtime.ts
+++ b/packages/opencode/src/effect/runtime.ts
@@ -1,6 +1,6 @@
import { Effect, Layer, ManagedRuntime } from "effect"
-import { AccountService } from "@/account/service"
-import { AuthService } from "@/auth/service"
+import { AccountEffect } from "@/account/effect"
+import { AuthEffect } from "@/auth/effect"
import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
@@ -8,10 +8,10 @@ import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make(
Layer.mergeAll(
- AccountService.defaultLayer, //
+ AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
- ).pipe(Layer.provideMerge(AuthService.defaultLayer)),
+ ).pipe(Layer.provideMerge(AuthEffect.layer)),
)
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index cee03e091..6e9b91727 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -1,272 +1,20 @@
import { BusEvent } from "@/bus/bus-event"
-import z from "zod"
+import { InstanceContext } from "@/effect/instance-context"
+import { runPromiseInstance } from "@/effect/runtime"
+import { git } from "@/util/git"
+import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
-import path from "path"
import fs from "fs"
-import ignore from "ignore"
-import { Log } from "../util/log"
-import { Filesystem } from "../util/filesystem"
-import { Instance } from "../project/instance"
-import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import path from "path"
+import z from "zod"
import { Global } from "../global"
-import { git } from "@/util/git"
+import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
+import { Log } from "../util/log"
import { Protected } from "./protected"
-import { InstanceContext } from "@/effect/instance-context"
-import { Effect, Layer, ServiceMap } from "effect"
-import { runPromiseInstance } from "@/effect/runtime"
-
-const log = Log.create({ service: "file" })
-
-const binaryExtensions = 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 imageExtensions = 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 textExtensions = 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 textNames = new Set([
- "dockerfile",
- "makefile",
- ".gitignore",
- ".gitattributes",
- ".editorconfig",
- ".npmrc",
- ".nvmrc",
- ".prettierrc",
- ".eslintrc",
-])
-
-function isImageByExtension(filepath: string): boolean {
- const ext = path.extname(filepath).toLowerCase().slice(1)
- return imageExtensions.has(ext)
-}
-
-function isTextByExtension(filepath: string): boolean {
- const ext = path.extname(filepath).toLowerCase().slice(1)
- return textExtensions.has(ext)
-}
-
-function isTextByName(filepath: string): boolean {
- const name = path.basename(filepath).toLowerCase()
- return textNames.has(name)
-}
-
-function getImageMimeType(filepath: string): string {
- const ext = path.extname(filepath).toLowerCase().slice(1)
- const mimeTypes: 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",
- }
- return mimeTypes[ext] || "image/" + ext
-}
-
-function isBinaryByExtension(filepath: string): boolean {
- const ext = path.extname(filepath).toLowerCase().slice(1)
- return binaryExtensions.has(ext)
-}
-
-function isImage(mimeType: string): boolean {
- return mimeType.startsWith("image/")
-}
-
-function shouldEncode(mimeType: string): boolean {
- 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 parts = type.split("/", 2)
- const top = parts[0]
-
- const tops = ["image", "audio", "video", "font", "model", "multipart"]
- if (tops.includes(top)) return true
-
- return false
-}
+import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
@@ -336,28 +84,270 @@ export namespace File {
}
export function init() {
- return runPromiseInstance(FileService.use((s) => s.init()))
+ return runPromiseInstance(Service.use((svc) => svc.init()))
}
export async function status() {
- return runPromiseInstance(FileService.use((s) => s.status()))
+ return runPromiseInstance(Service.use((svc) => svc.status()))
}
export async function read(file: string): Promise<Content> {
- return runPromiseInstance(FileService.use((s) => s.read(file)))
+ return runPromiseInstance(Service.use((svc) => svc.read(file)))
}
export async function list(dir?: string) {
- return runPromiseInstance(FileService.use((s) => s.list(dir)))
+ return runPromiseInstance(Service.use((svc) => svc.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
- return runPromiseInstance(FileService.use((s) => s.search(input)))
+ return runPromiseInstance(Service.use((svc) => svc.search(input)))
+ }
+
+ 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 namespace FileService {
- export interface Service {
+ export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content>
@@ -369,89 +359,83 @@ export namespace FileService {
type?: "file" | "directory"
}) => Effect.Effect<string[]>
}
-}
-export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
- static readonly layer = Layer.effect(
- FileService,
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
+
+ export const layer = Layer.effect(
+ Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
-
- // File cache state
- type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] }
- let task: Promise<void> | undefined
-
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
- function kick() {
- if (task) return task
- task = (async () => {
- // Disable scanning if in root of file system
- if (instance.directory === path.parse(instance.directory).root) return
- const next: Entry = { files: [], dirs: [] }
- try {
- 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 + "/")
- }
+ 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 set = 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 (set.has(dir)) continue
- set.add(dir)
- next.dirs.push(dir + "/")
- }
+ 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
- } finally {
- task = undefined
}
- })()
- return task
- }
+ })
- const getFiles = async () => {
- void kick()
- return cache
- }
+ cache = next
+ })
- const init = Effect.fn("FileService.init")(function* () {
- yield* Effect.promise(() => kick())
+ 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("FileService.status")(function* () {
+ const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
@@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
})
).text()
- const changedFiles: File.Info[] = []
+ const changed: File.Info[] = []
if (diffOutput.trim()) {
- const lines = diffOutput.trim().split("\n")
- for (const line of lines) {
- const [added, removed, filepath] = line.split("\t")
- changedFiles.push({
- path: filepath,
+ 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",
@@ -494,14 +477,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text()
if (untrackedOutput.trim()) {
- const untrackedFiles = untrackedOutput.trim().split("\n")
- for (const filepath of untrackedFiles) {
+ for (const file of untrackedOutput.trim().split("\n")) {
try {
- const content = await Filesystem.readText(path.join(instance.directory, filepath))
- const lines = content.split("\n").length
- changedFiles.push({
- path: filepath,
- added: lines,
+ const content = await Filesystem.readText(path.join(instance.directory, file))
+ changed.push({
+ path: file,
+ added: content.split("\n").length,
removed: 0,
status: "added",
})
@@ -511,7 +492,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}
}
- // Get deleted files
const deletedOutput = (
await git(
[
@@ -531,50 +511,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text()
if (deletedOutput.trim()) {
- const deletedFiles = deletedOutput.trim().split("\n")
- for (const filepath of deletedFiles) {
- changedFiles.push({
- path: filepath,
+ for (const file of deletedOutput.trim().split("\n")) {
+ changed.push({
+ path: file,
added: 0,
- removed: 0, // Could get original line count but would require another git command
+ removed: 0,
status: "deleted",
})
}
}
- return changedFiles.map((x) => {
- const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
+ return changed.map((item) => {
+ const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return {
- ...x,
+ ...item,
path: path.relative(instance.directory, full),
}
})
})
})
- const read = Effect.fn("FileService.read")(function* (file: string) {
+ 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`)
+ throw new Error("Access denied: path escapes project directory")
}
- // Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
- const content = buffer.toString("base64")
- const mimeType = getImageMimeType(file)
- return { type: "text", content, mimeType, encoding: "base64" }
+ return {
+ type: "text",
+ content: buffer.toString("base64"),
+ mimeType: getImageMimeType(file),
+ encoding: "base64",
+ }
}
return { type: "text", content: "" }
}
- const text = isTextByExtension(file) || isTextByName(file)
+ const knownText = isTextByExtension(file) || isTextByName(file)
- if (isBinaryByExtension(file) && !text) {
+ if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" }
}
@@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}
const mimeType = Filesystem.mimeType(full)
- const encode = text ? false : shouldEncode(mimeType)
+ const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType }
@@ -591,8 +572,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
- const content = buffer.toString("base64")
- return { type: "text", content, mimeType, encoding: "base64" }
+ return {
+ type: "text",
+ content: buffer.toString("base64"),
+ mimeType,
+ encoding: "base64",
+ }
}
const content = (await Filesystem.readText(full).catch(() => "")).trim()
@@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text()
if (!diff.trim()) {
diff = (
- await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
+ await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
+ cwd: instance.directory,
+ })
).text()
}
if (diff.trim()) {
@@ -612,64 +599,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
context: Infinity,
ignoreWhitespace: true,
})
- const diff = formatPatch(patch)
- return { type: "text", content, patch, diff }
+ return {
+ type: "text",
+ content,
+ patch,
+ diff: formatPatch(patch),
+ }
}
}
+
return { type: "text", content }
})
})
- const list = Effect.fn("FileService.list")(function* (dir?: string) {
+ 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 gitignorePath = path.join(instance.project.worktree, ".gitignore")
- if (await Filesystem.exists(gitignorePath)) {
- ig.add(await Filesystem.readText(gitignorePath))
+ const gitignore = path.join(instance.project.worktree, ".gitignore")
+ if (await Filesystem.exists(gitignore)) {
+ ig.add(await Filesystem.readText(gitignore))
}
- const ignorePath = path.join(instance.project.worktree, ".ignore")
- if (await Filesystem.exists(ignorePath)) {
- ig.add(await Filesystem.readText(ignorePath))
+ 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
+ const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) {
- throw new Error(`Access denied: path escapes project directory`)
+ 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(() => [])) {
+ for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
if (exclude.includes(entry.name)) continue
- const fullPath = path.join(resolved, entry.name)
- const relativePath = path.relative(instance.directory, fullPath)
+ 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: relativePath,
- absolute: fullPath,
+ path: file,
+ absolute,
type,
- ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
+ ignored: ignored(type === "directory" ? file + "/" : file),
})
}
+
return nodes.sort((a, b) => {
- if (a.type !== b.type) {
- return a.type === "directory" ? -1 : 1
- }
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.name.localeCompare(b.name)
})
})
})
- const search = Effect.fn("FileService.search")(function* (input: {
+ const search = Effect.fn("File.search")(function* (input: {
query: string
limit?: number
dirs?: boolean
@@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
- const result = await getFiles()
-
- const hidden = (item: string) => {
- const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
- return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
- }
+ const result = getFiles()
const preferHidden = query.startsWith(".") || query.includes("/.")
- const sortHiddenLast = (items: string[]) => {
- if (preferHidden) return items
- const visible: string[] = []
- const hiddenItems: string[] = []
- for (const item of items) {
- const isHidden = hidden(item)
- if (isHidden) hiddenItems.push(item)
- if (!isHidden) visible.push(item)
- }
- return [...visible, ...hiddenItems]
- }
+
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
- return sortHiddenLast(result.dirs.toSorted()).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((r) => r.target)
- const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
+ 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
@@ -717,8 +689,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
})
log.info("init")
-
- return FileService.of({ init, status, read, list, search })
+ return Service.of({ init, status, read, list, search })
}),
)
}
diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts
index c956cdfdb..3d94bc122 100644
--- a/packages/opencode/src/file/time.ts
+++ b/packages/opencode/src/file/time.ts
@@ -1,115 +1,110 @@
-import { Log } from "../util/log"
-import { Flag } from "@/flag/flag"
-import { Filesystem } from "../util/filesystem"
-import { Effect, Layer, ServiceMap, Semaphore } from "effect"
+import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
+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" })
-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 namespace FileTimeService {
- export interface Service {
+ 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>
}
-}
-
-type Stamp = {
- readonly read: Date
- readonly mtime: number | undefined
- readonly ctime: number | undefined
- readonly size: number | undefined
-}
-
-function stamp(file: string): Stamp {
- const stat = Filesystem.stat(file)
- const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
- return {
- read: new Date(),
- mtime: stat?.mtime?.getTime(),
- ctime: stat?.ctime?.getTime(),
- size,
- }
-}
-function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
- let value = reads.get(sessionID)
- if (!value) {
- value = new Map<string, Stamp>()
- reads.set(sessionID, value)
- }
- return value
-}
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
-export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
- "@opencode/FileTime",
-) {
- static readonly layer = Layer.effect(
- FileTimeService,
+ 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>()
- function getLock(filepath: string) {
- let lock = locks.get(filepath)
- if (!lock) {
- lock = Semaphore.makeUnsafe(1)
- locks.set(filepath, lock)
- }
- return lock
+ const getLock = (filepath: string) => {
+ const lock = locks.get(filepath)
+ if (lock) return lock
+
+ const next = Semaphore.makeUnsafe(1)
+ locks.set(filepath, next)
+ return next
}
- return FileTimeService.of({
- read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
- log.info("read", { sessionID, file })
- session(reads, sessionID).set(file, stamp(file))
- }),
-
- get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
- return reads.get(sessionID)?.get(file)?.read
- }),
-
- assert: Effect.fn("FileTimeService.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 = stamp(filepath)
- const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
-
- if (changed) {
- 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.`,
- )
- }
- }),
-
- withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
- const lock = getLock(filepath)
- return yield* Effect.promise(fn).pipe(lock.withPermits(1))
- }),
+ 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 })
}),
)
-}
-export namespace FileTime {
export function read(sessionID: SessionID, file: string) {
- return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
+ return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
}
export function get(sessionID: SessionID, file: string) {
- return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
+ return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
}
export async function assert(sessionID: SessionID, filepath: string) {
- return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
+ return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
- return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
+ return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
}
}
diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts
index 16ee8f27c..ad683303c 100644
--- a/packages/opencode/src/file/watcher.ts
+++ b/packages/opencode/src/file/watcher.ts
@@ -1,89 +1,76 @@
-import { BusEvent } from "@/bus/bus-event"
-import { Bus } from "@/bus"
-import { InstanceContext } from "@/effect/instance-context"
-import { Instance } from "@/project/instance"
-import z from "zod"
-import { Log } from "../util/log"
-import { FileIgnore } from "./ignore"
-import { Config } from "../config/config"
-import path from "path"
+import { Cause, Effect, Layer, ServiceMap } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
-import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises"
+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 { Flag } from "@/flag/flag"
+import { Instance } from "@/project/instance"
import { git } from "@/util/git"
+import { lazy } from "@/util/lazy"
+import { Config } from "../config/config"
+import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
-import { Flag } from "@/flag/flag"
-import { Cause, Effect, Layer, ServiceMap } from "effect"
-
-const SUBSCRIBE_TIMEOUT_MS = 10_000
+import { Log } from "../util/log"
declare const OPENCODE_LIBC: string | undefined
-const log = Log.create({ service: "file.watcher" })
-
-const event = {
- Updated: BusEvent.define(
- "file.watcher.updated",
- z.object({
- file: z.string(),
- event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
- }),
- ),
-}
-
-const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
- try {
- const binding = require(
- `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
- )
- return createWrapper(binding) as typeof import("@parcel/watcher")
- } catch (error) {
- log.error("failed to load watcher binding", { error })
- return
+export namespace FileWatcher {
+ const log = Log.create({ service: "file.watcher" })
+ const SUBSCRIBE_TIMEOUT_MS = 10_000
+
+ export const Event = {
+ Updated: BusEvent.define(
+ "file.watcher.updated",
+ z.object({
+ file: z.string(),
+ event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
+ }),
+ ),
}
-})
-function getBackend() {
- if (process.platform === "win32") return "windows"
- if (process.platform === "darwin") return "fs-events"
- if (process.platform === "linux") return "inotify"
-}
+ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
+ try {
+ const binding = require(
+ `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
+ )
+ return createWrapper(binding) as typeof import("@parcel/watcher")
+ } catch (error) {
+ log.error("failed to load watcher binding", { error })
+ return
+ }
+ })
+
+ function getBackend() {
+ if (process.platform === "win32") return "windows"
+ if (process.platform === "darwin") return "fs-events"
+ if (process.platform === "linux") return "inotify"
+ }
-export namespace FileWatcher {
- export const Event = event
- /** Whether the native @parcel/watcher binding is available on this platform. */
export const hasNativeBinding = () => !!watcher()
-}
-
-const init = Effect.fn("FileWatcherService.init")(function* () {})
-export namespace FileWatcherService {
- export interface Service {
- readonly init: () => Effect.Effect<void>
- }
-}
+ export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
-export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
- "@opencode/FileWatcher",
-) {
- static readonly layer = Layer.effect(
- FileWatcherService,
+ export const layer = Layer.effect(
+ Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
- if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
+ 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 FileWatcherService.of({ init })
+ return Service.of({})
}
const w = watcher()
- if (!w) return FileWatcherService.of({ init })
+ if (!w) return Service.of({})
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
@@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
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" })
+ 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" })
}
})
@@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
- // Clean up a subscription that resolves after timeout
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
@@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
}
}
- return FileWatcherService.of({ init })
+ return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
- return Effect.succeed(FileWatcherService.of({ init }))
+ return Effect.succeed(Service.of({}))
}),
),
)
diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index cb71fc363..6da8caa08 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -1,21 +1,20 @@
-import { Bus } from "../bus"
-import { File } from "../file"
-import { Log } from "../util/log"
+import { Effect, Layer, ServiceMap } from "effect"
+import { runPromiseInstance } from "@/effect/runtime"
+import { InstanceContext } from "@/effect/instance-context"
import path from "path"
+import { mergeDeep } from "remeda"
import z from "zod"
-
-import * as Formatter from "./formatter"
+import { Bus } from "../bus"
import { Config } from "../config/config"
-import { mergeDeep } from "remeda"
+import { File } from "../file"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
-import { InstanceContext } from "@/effect/instance-context"
-import { Effect, Layer, ServiceMap } from "effect"
-import { runPromiseInstance } from "@/effect/runtime"
-
-const log = Log.create({ service: "format" })
+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(),
@@ -27,25 +26,14 @@ export namespace Format {
})
export type Status = z.infer<typeof Status>
- export async function init() {
- return runPromiseInstance(FormatService.use((s) => s.init()))
- }
-
- export async function status() {
- return runPromiseInstance(FormatService.use((s) => s.status()))
+ export interface Interface {
+ readonly status: () => Effect.Effect<Status[]>
}
-}
-export namespace FormatService {
- export interface Service {
- readonly init: () => Effect.Effect<void>
- readonly status: () => Effect.Effect<Format.Status[]>
- }
-}
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
-export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
- static readonly layer = Layer.effect(
- FormatService,
+ export const layer = Layer.effect(
+ Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
@@ -63,17 +51,19 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
delete formatters[name]
continue
}
- const result = mergeDeep(formatters[name] ?? {}, {
+ const info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
- }) as Formatter.Info
+ })
- if (result.command.length === 0) continue
+ if (info.command.length === 0) continue
- result.enabled = async () => true
- result.name = name
- formatters[name] = result
+ formatters[name] = {
+ ...info,
+ name,
+ enabled: async () => true,
+ }
}
} else {
log.info("all formatters are disabled")
@@ -100,50 +90,52 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
return result
}
- const unsubscribe = 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,
- })
- }
- }
- }),
+ 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),
)
-
- yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
log.info("init")
- const init = Effect.fn("FormatService.init")(function* () {})
-
- const status = Effect.fn("FormatService.status")(function* () {
- const result: Format.Status[] = []
+ 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({
@@ -155,7 +147,11 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
return result
})
- return FormatService.of({ init, status })
+ return Service.of({ status })
}),
)
+
+ export async function status() {
+ return runPromiseInstance(Service.use((s) => s.status()))
+ }
}
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index e7eb0eea6..93a8c49b6 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -1,11 +1,251 @@
import { runPromiseInstance } from "@/effect/runtime"
+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 { fn } from "@/util/fn"
+import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
-import { PermissionEffect as S } from "./service"
+import z from "zod"
+import { PermissionID } from "./schema"
export namespace PermissionNext {
+ 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 {
+ const rules = rulesets.flat()
+ log.info("evaluate", { permission, pattern, ruleset: rules })
+ const match = rules.findLast(
+ (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+ )
+ return match ?? { action: "ask", permission, pattern: "*" }
+ }
+
+ 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 })
+ }),
+ )
+
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
@@ -14,32 +254,11 @@ export namespace PermissionNext {
return pattern
}
- export const Action = S.Action
- export type Action = S.Action
- export const Rule = S.Rule
- export type Rule = S.Rule
- export const Ruleset = S.Ruleset
- export type Ruleset = S.Ruleset
- export const Request = S.Request
- export type Request = S.Request
- export const Reply = S.Reply
- export type Reply = S.Reply
- export const Approval = S.Approval
- export const Event = S.Event
- export const Service = S.Service
- export const RejectedError = S.RejectedError
- export const CorrectedError = S.CorrectedError
- export const DeniedError = S.DeniedError
-
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: "*",
- })
+ ruleset.push({ permission: key, action: value, pattern: "*" })
continue
}
ruleset.push(
@@ -53,18 +272,12 @@ export namespace PermissionNext {
return rulesets.flat()
}
- export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
+ export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
- export const reply = fn(S.ReplyInput, async (input) =>
- runPromiseInstance(S.Service.use((service) => service.reply(input))),
- )
+ export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
export async function list() {
- return runPromiseInstance(S.Service.use((service) => service.list()))
- }
-
- export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
- return S.evaluate(permission, pattern, ...rulesets)
+ return runPromiseInstance(Service.use((svc) => svc.list()))
}
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts
deleted file mode 100644
index 4335aa4cd..000000000
--- a/packages/opencode/src/permission/service.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
-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 z from "zod"
-import { PermissionID } from "./schema"
-
-export namespace PermissionEffect {
- 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 Api {
- 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 {
- const rules = rulesets.flat()
- log.info("evaluate", { permission, pattern, ruleset: rules })
- const match = rules.findLast(
- (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
- )
- return match ?? { action: "ask", permission, pattern: "*" }
- }
-
- export class Service extends ServiceMap.Service<Service, Api>()("@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("PermissionService.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("PermissionService.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("PermissionService.list")(function* () {
- return Array.from(pending.values(), (item) => item.info)
- })
-
- return Service.of({ ask, reply, list })
- }),
- )
-}
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index 40a4ce9cc..86403f3da 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -1,30 +1,23 @@
import { Plugin } from "../plugin"
-import { Format } from "../format"
import { LSP } from "../lsp"
-import { FileWatcherService } from "../file/watcher"
import { File } from "../file"
import { Project } from "./project"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
-import { VcsService } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
-import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
ShareNext.init()
- await Format.init()
await LSP.init()
- await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init()
- await runPromiseInstance(VcsService.use((s) => s.init()))
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
- await Project.setInitialized(Instance.project.id)
+ Project.setInitialized(Instance.project.id)
}
})
}
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
index 4d1f7b766..9e85571c4 100644
--- a/packages/opencode/src/project/vcs.ts
+++ b/packages/opencode/src/project/vcs.ts
@@ -1,16 +1,16 @@
-import { BusEvent } from "@/bus/bus-event"
+import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
-import z from "zod"
-import { Log } from "@/util/log"
-import { Instance } from "./instance"
+import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
+import { Log } from "@/util/log"
import { git } from "@/util/git"
-import { Effect, Layer, ServiceMap } from "effect"
-
-const log = Log.create({ service: "vcs" })
+import { Instance } from "./instance"
+import z from "zod"
export namespace Vcs {
+ const log = Log.create({ service: "vcs" })
+
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
@@ -28,24 +28,21 @@ export namespace Vcs {
ref: "VcsInfo",
})
export type Info = z.infer<typeof Info>
-}
-export namespace VcsService {
- export interface Service {
- readonly init: () => Effect.Effect<void>
+ export interface Interface {
readonly branch: () => Effect.Effect<string | undefined>
}
-}
-export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
- static readonly layer = Layer.effect(
- VcsService,
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
+
+ export const layer = Layer.effect(
+ Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
- let current: string | undefined
+ let currentBranch: string | undefined
if (instance.project.vcs === "git") {
- const currentBranch = async () => {
+ const getCurrentBranch = async () => {
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: instance.project.worktree,
})
@@ -54,29 +51,31 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
return text || undefined
}
- current = yield* Effect.promise(() => currentBranch())
- log.info("initialized", { branch: current })
+ currentBranch = yield* Effect.promise(() => getCurrentBranch())
+ log.info("initialized", { branch: currentBranch })
- const unsubscribe = Bus.subscribe(
- FileWatcher.Event.Updated,
- Instance.bind(async (evt) => {
- if (!evt.properties.file.endsWith("HEAD")) return
- const next = await currentBranch()
- if (next !== current) {
- log.info("branch changed", { from: current, to: next })
- current = next
- Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
- }
- }),
+ 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),
)
-
- yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
}
- return VcsService.of({
- init: Effect.fn("VcsService.init")(function* () {}),
- branch: Effect.fn("VcsService.branch")(function* () {
- return current
+ return Service.of({
+ branch: Effect.fn("Vcs.branch")(function* () {
+ return currentBranch
}),
})
}),
diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts
deleted file mode 100644
index 900b31d10..000000000
--- a/packages/opencode/src/provider/auth-service.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import type { AuthOuathResult } from "@opencode-ai/plugin"
-import { NamedError } from "@opencode-ai/util/error"
-import * as Auth from "@/auth/service"
-import { ProviderID } from "./schema"
-import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
-import { filter, fromEntries, map, pipe } from "remeda"
-import z from "zod"
-
-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 ProviderAuthError =
- | Auth.AuthServiceError
- | InstanceType<typeof OauthMissing>
- | InstanceType<typeof OauthCodeMissing>
- | InstanceType<typeof OauthCallbackFailed>
- | InstanceType<typeof ValidationFailed>
-
-export namespace ProviderAuthService {
- export interface Service {
- readonly methods: () => Effect.Effect<Record<string, Method[]>>
- readonly authorize: (input: {
- providerID: ProviderID
- method: number
- inputs?: Record<string, string>
- }) => Effect.Effect<Authorization | undefined, ProviderAuthError>
- readonly callback: (input: {
- providerID: ProviderID
- method: number
- code?: string
- }) => Effect.Effect<void, ProviderAuthError>
- }
-}
-
-export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
- "@opencode/ProviderAuth",
-) {
- static readonly layer = Layer.effect(
- ProviderAuthService,
- Effect.gen(function* () {
- const auth = yield* Auth.AuthService
- const hooks = yield* Effect.promise(async () => {
- const mod = await import("../plugin")
- return pipe(
- await mod.Plugin.list(),
- filter((x) => x.auth?.provider !== undefined),
- map((x) => [x.auth!.provider, x.auth!] as const),
- fromEntries(),
- )
- })
- const pending = new Map<ProviderID, AuthOuathResult>()
-
- const methods = Effect.fn("ProviderAuthService.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("ProviderAuthService.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("ProviderAuthService.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 ProviderAuthService.of({
- methods,
- authorize,
- callback,
- })
- }),
- )
-
- static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
-}
diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts
index 912b12076..5204b5fb8 100644
--- a/packages/opencode/src/provider/auth.ts
+++ b/packages/opencode/src/provider/auth.ts
@@ -1,20 +1,223 @@
-import z from "zod"
-
+import type { AuthOuathResult } from "@opencode-ai/plugin"
+import { NamedError } from "@opencode-ai/util/error"
+import * as Auth from "@/auth/effect"
import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn"
-import * as S from "./auth-service"
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 = 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 async function methods() {
- return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
+ 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 const Authorization = S.Authorization
- export type Authorization = S.Authorization
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const auth = yield* Auth.AuthEffect.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 })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
+
+ export async function methods() {
+ return runPromiseInstance(Service.use((svc) => svc.methods()))
+ }
export const authorize = fn(
z.object({
@@ -22,8 +225,7 @@ export namespace ProviderAuth {
method: z.number(),
inputs: z.record(z.string(), z.string()).optional(),
}),
- async (input): Promise<Authorization | undefined> =>
- runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
+ async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
)
export const callback = fn(
@@ -32,11 +234,6 @@ export namespace ProviderAuth {
method: z.number(),
code: z.string().optional(),
}),
- async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
+ async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
)
-
- export import OauthMissing = S.OauthMissing
- export import OauthCodeMissing = S.OauthCodeMissing
- export import OauthCallbackFailed = S.OauthCallbackFailed
- export import ValidationFailed = S.ValidationFailed
}
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
index 7fffc0c87..551c51399 100644
--- a/packages/opencode/src/question/index.ts
+++ b/packages/opencode/src/question/index.ts
@@ -1,39 +1,193 @@
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
-import * as S from "./service"
-import type { QuestionID } from "./schema"
-import type { SessionID, MessageID } from "@/session/schema"
+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 {
- export const Option = S.Option
- export type Option = S.Option
- export const Info = S.Info
- export type Info = S.Info
- export const Request = S.Request
- export type Request = S.Request
- export const Answer = S.Answer
- export type Answer = S.Answer
- export const Reply = S.Reply
- export type Reply = S.Reply
- export const Event = S.Event
- export const RejectedError = S.RejectedError
+ // 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 })
+ }),
+ )
export async function ask(input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> {
- return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
+ return runPromiseInstance(Service.use((svc) => svc.ask(input)))
}
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
- return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
+ return runPromiseInstance(Service.use((svc) => svc.reply(input)))
}
export async function reject(requestID: QuestionID): Promise<void> {
- return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
+ return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
}
export async function list(): Promise<Request[]> {
- return runPromiseInstance(S.QuestionService.use((service) => service.list()))
+ return runPromiseInstance(Service.use((svc) => svc.list()))
}
}
diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts
deleted file mode 100644
index 3df8286e6..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" })
-
-// --- Zod schemas (re-exported by facade) ---
-
-export const Option = z
- .object({
- label: z.string().describe("Display text (1-5 words, concise)"),
- description: z.string().describe("Explanation of choice"),
- })
- .meta({ ref: "QuestionOption" })
-export type Option = z.infer<typeof Option>
-
-export const Info = z
- .object({
- question: z.string().describe("Complete question"),
- header: z.string().describe("Very short label (max 30 chars)"),
- options: z.array(Option).describe("Available choices"),
- multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
- custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
- })
- .meta({ ref: "QuestionInfo" })
-export type Info = z.infer<typeof Info>
-
-export const Request = z
- .object({
- id: QuestionID.zod,
- sessionID: SessionID.zod,
- questions: z.array(Info).describe("Questions to ask"),
- tool: z
- .object({
- messageID: MessageID.zod,
- callID: z.string(),
- })
- .optional(),
- })
- .meta({ ref: "QuestionRequest" })
-export type Request = z.infer<typeof Request>
-
-export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
-export type Answer = z.infer<typeof Answer>
-
-export const Reply = z.object({
- answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
-})
-export type Reply = z.infer<typeof Reply>
-
-export const Event = {
- Asked: BusEvent.define("question.asked", Request),
- Replied: BusEvent.define(
- "question.replied",
- z.object({
- sessionID: SessionID.zod,
- requestID: QuestionID.zod,
- answers: z.array(Answer),
- }),
- ),
- Rejected: BusEvent.define(
- "question.rejected",
- z.object({
- sessionID: SessionID.zod,
- requestID: QuestionID.zod,
- }),
- ),
-}
-
-export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
- override get message() {
- return "The user dismissed this question"
- }
-}
-
-// --- Effect service ---
-
-interface PendingEntry {
- info: Request
- deferred: Deferred.Deferred<Answer[], RejectedError>
-}
-
-export namespace QuestionService {
- export interface Service {
- readonly ask: (input: {
- sessionID: SessionID
- questions: Info[]
- tool?: { messageID: MessageID; callID: string }
- }) => Effect.Effect<Answer[], RejectedError>
- readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
- readonly reject: (requestID: QuestionID) => Effect.Effect<void>
- readonly list: () => Effect.Effect<Request[]>
- }
-}
-
-export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
- "@opencode/Question",
-) {
- static readonly layer = Layer.effect(
- QuestionService,
- Effect.gen(function* () {
- const pending = new Map<QuestionID, PendingEntry>()
-
- const ask = Effect.fn("QuestionService.ask")(function* (input: {
- sessionID: SessionID
- questions: Info[]
- tool?: { messageID: MessageID; callID: string }
- }) {
- const id = QuestionID.ascending()
- log.info("asking", { id, questions: input.questions.length })
-
- const deferred = yield* Deferred.make<Answer[], RejectedError>()
- const info: Request = {
- id,
- sessionID: input.sessionID,
- questions: input.questions,
- tool: input.tool,
- }
- pending.set(id, { info, deferred })
- Bus.publish(Event.Asked, info)
-
- return yield* Effect.ensuring(
- Deferred.await(deferred),
- Effect.sync(() => {
- pending.delete(id)
- }),
- )
- })
-
- const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
- const existing = pending.get(input.requestID)
- if (!existing) {
- log.warn("reply for unknown request", { requestID: input.requestID })
- return
- }
- pending.delete(input.requestID)
- log.info("replied", { requestID: input.requestID, answers: input.answers })
- Bus.publish(Event.Replied, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- answers: input.answers,
- })
- yield* Deferred.succeed(existing.deferred, input.answers)
- })
-
- const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
- const existing = pending.get(requestID)
- if (!existing) {
- log.warn("reject for unknown request", { requestID })
- return
- }
- pending.delete(requestID)
- log.info("rejected", { requestID })
- Bus.publish(Event.Rejected, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- })
- yield* Deferred.fail(existing.deferred, new RejectedError())
- })
-
- const list = Effect.fn("QuestionService.list")(function* () {
- return Array.from(pending.values(), (x) => x.info)
- })
-
- return QuestionService.of({ ask, reply, reject, list })
- }),
- )
-}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 1904706a1..c485654fd 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format"
import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
-import { Vcs, VcsService } from "../project/vcs"
+import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill"
@@ -332,7 +332,7 @@ export namespace Server {
},
}),
async (c) => {
- const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
+ const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
return c.json({
branch,
})
diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts
index fe03dccef..e5279503d 100644
--- a/packages/opencode/src/skill/discovery.ts
+++ b/packages/opencode/src/skill/discovery.ts
@@ -1,116 +1,117 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
import { Log } from "../util/log"
-import { withTransientReadRetry } from "@/util/effect-http-client"
-class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
- name: Schema.String,
- files: Schema.Array(Schema.String),
-}) {}
+export namespace Discovery {
+ const skillConcurrency = 4
+ const fileConcurrency = 8
-class Index extends Schema.Class<Index>("Index")({
- skills: Schema.Array(IndexSkill),
-}) {}
+ class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
+ name: Schema.String,
+ files: Schema.Array(Schema.String),
+ }) {}
-const skillConcurrency = 4
-const fileConcurrency = 8
+ class Index extends Schema.Class<Index>("Index")({
+ skills: Schema.Array(IndexSkill),
+ }) {}
-export namespace DiscoveryService {
- export interface Service {
+ export interface Interface {
readonly pull: (url: string) => Effect.Effect<string[]>
}
-}
-export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()(
- "@opencode/SkillDiscovery",
-) {
- static readonly layer = Layer.effect(
- DiscoveryService,
- Effect.gen(function* () {
- const log = Log.create({ service: "skill-discovery" })
- const fs = yield* FileSystem.FileSystem
- const path = yield* Path.Path
- const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
- const cache = path.join(Global.Path.cache, "skills")
-
- const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) {
- if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
-
- return yield* HttpClientRequest.get(url).pipe(
- http.execute,
- Effect.flatMap((res) => res.arrayBuffer),
- Effect.flatMap((body) =>
- fs
- .makeDirectory(path.dirname(dest), { recursive: true })
- .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
- ),
- Effect.as(true),
- Effect.catch((err) =>
- Effect.sync(() => {
- log.error("failed to download", { url, err })
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
+
+ export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const log = Log.create({ service: "skill-discovery" })
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
+ const cache = path.join(Global.Path.cache, "skills")
+
+ const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
+ if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
+
+ return yield* HttpClientRequest.get(url).pipe(
+ http.execute,
+ Effect.flatMap((res) => res.arrayBuffer),
+ Effect.flatMap((body) =>
+ fs
+ .makeDirectory(path.dirname(dest), { recursive: true })
+ .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
+ ),
+ Effect.as(true),
+ Effect.catch((err) =>
+ Effect.sync(() => {
+ log.error("failed to download", { url, err })
+ return false
+ }),
+ ),
+ )
+ })
+
+ const pull = Effect.fn("Discovery.pull")(function* (url: string) {
+ const base = url.endsWith("/") ? url : `${url}/`
+ const index = new URL("index.json", base).href
+ const host = base.slice(0, -1)
+
+ log.info("fetching index", { url: index })
+
+ const data = yield* HttpClientRequest.get(index).pipe(
+ HttpClientRequest.acceptJson,
+ http.execute,
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
+ Effect.catch((err) =>
+ Effect.sync(() => {
+ log.error("failed to fetch index", { url: index, err })
+ return null
+ }),
+ ),
+ )
+
+ if (!data) return []
+
+ const list = data.skills.filter((skill) => {
+ if (!skill.files.includes("SKILL.md")) {
+ log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return false
- }),
- ),
- )
- })
-
- const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) {
- const base = url.endsWith("/") ? url : `${url}/`
- const index = new URL("index.json", base).href
- const host = base.slice(0, -1)
-
- log.info("fetching index", { url: index })
-
- const data = yield* HttpClientRequest.get(index).pipe(
- HttpClientRequest.acceptJson,
- http.execute,
- Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
- Effect.catch((err) =>
- Effect.sync(() => {
- log.error("failed to fetch index", { url: index, err })
- return null
- }),
- ),
- )
-
- if (!data) return []
-
- const list = data.skills.filter((skill) => {
- if (!skill.files.includes("SKILL.md")) {
- log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
- return false
- }
- return true
+ }
+ return true
+ })
+
+ const dirs = yield* Effect.forEach(
+ list,
+ (skill) =>
+ Effect.gen(function* () {
+ const root = path.join(cache, skill.name)
+
+ yield* Effect.forEach(
+ skill.files,
+ (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
+ {
+ concurrency: fileConcurrency,
+ },
+ )
+
+ const md = path.join(root, "SKILL.md")
+ return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
+ }),
+ { concurrency: skillConcurrency },
+ )
+
+ return dirs.filter((dir): dir is string => dir !== null)
})
- const dirs = yield* Effect.forEach(
- list,
- (skill) =>
- Effect.gen(function* () {
- const root = path.join(cache, skill.name)
-
- yield* Effect.forEach(
- skill.files,
- (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
- { concurrency: fileConcurrency },
- )
-
- const md = path.join(root, "SKILL.md")
- return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
- }),
- { concurrency: skillConcurrency },
- )
-
- return dirs.filter((dir): dir is string => dir !== null)
- })
-
- return DiscoveryService.of({ pull })
- }),
- )
+ return Service.of({ pull })
+ }),
+ )
- static readonly defaultLayer = DiscoveryService.layer.pipe(
+ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 79be9f779..d7aeb911f 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -1,34 +1,30 @@
-import z from "zod"
-import path from "path"
import os from "os"
-import { Config } from "../config/config"
-import { Instance } from "../project/instance"
-import { NamedError } from "@opencode-ai/util/error"
-import { ConfigMarkdown } from "../config/markdown"
-import { Log } from "../util/log"
-import { Global } from "@/global"
-import { Filesystem } from "@/util/filesystem"
-import { Flag } from "@/flag/flag"
-import { Bus } from "@/bus"
-import { DiscoveryService } from "./discovery"
-import { Glob } from "../util/glob"
+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 { PermissionNext } from "@/permission"
+import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
-import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
-
-const log = Log.create({ service: "skill" })
-
-// External skill directories to search for (project-level and global)
-// These follow the directory layout used by Claude Code and other agents.
-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"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { PermissionNext } 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(),
@@ -55,213 +51,205 @@ export namespace Skill {
}),
)
+ 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)
+ if (!agent) return list
+ return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
+ })
+
+ return Service.of({ get, all, dirs, available })
+ }),
+ )
+
+ export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
+ Layer.provide(Discovery.defaultLayer),
+ )
+
export async function get(name: string) {
- return runPromiseInstance(SkillService.use((s) => s.get(name)))
+ return runPromiseInstance(Service.use((skill) => skill.get(name)))
}
export async function all() {
- return runPromiseInstance(SkillService.use((s) => s.all()))
+ return runPromiseInstance(Service.use((skill) => skill.all()))
}
export async function dirs() {
- return runPromiseInstance(SkillService.use((s) => s.dirs()))
+ return runPromiseInstance(Service.use((skill) => skill.dirs()))
}
export async function available(agent?: Agent.Info) {
- return runPromiseInstance(SkillService.use((s) => s.available(agent)))
+ return runPromiseInstance(Service.use((skill) => skill.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
- if (list.length === 0) {
- return "No skills are currently available."
- }
+ if (list.length === 0) return "No skills are currently available."
+
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
- ` <skill>`,
+ " <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
- ` </skill>`,
+ " </skill>",
]),
"</available_skills>",
].join("\n")
}
- return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
- }
-}
-export namespace SkillService {
- export interface Service {
- readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
- readonly all: () => Effect.Effect<Skill.Info[]>
- readonly dirs: () => Effect.Effect<string[]>
- readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
+ return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
}
-
-export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
- static readonly layer = Layer.effect(
- SkillService,
- Effect.gen(function* () {
- const instance = yield* InstanceContext
- const discovery = yield* DiscoveryService
-
- const skills: Record<string, Skill.Info> = {}
- const skillDirs = new Set<string>()
- let task: Promise<void> | undefined
-
- const addSkill = async (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 = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
- if (!parsed.success) return
-
- // Warn on duplicate skill names
- if (skills[parsed.data.name]) {
- log.warn("duplicate skill name", {
- name: parsed.data.name,
- existing: skills[parsed.data.name].location,
- duplicate: match,
- })
- }
-
- skillDirs.add(path.dirname(match))
-
- skills[parsed.data.name] = {
- name: parsed.data.name,
- description: parsed.data.description,
- location: match,
- content: md.content,
- }
- }
-
- const scanExternal = async (root: string, scope: "global" | "project") => {
- return Glob.scan(EXTERNAL_SKILL_PATTERN, {
- cwd: root,
- absolute: true,
- include: "file",
- dot: true,
- symlink: true,
- })
- .then((matches) => Promise.all(matches.map(addSkill)))
- .catch((error) => {
- log.error(`failed to scan ${scope} skills`, { dir: root, error })
- })
- }
-
- function ensureScanned() {
- if (task) return task
- task = (async () => {
- // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
- // Load global (home) first, then project-level (so project-level overwrites)
- 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 scanExternal(root, "global")
- }
-
- for await (const root of Filesystem.up({
- targets: EXTERNAL_DIRS,
- start: instance.directory,
- stop: instance.project.worktree,
- })) {
- await scanExternal(root, "project")
- }
- }
-
- // Scan .opencode/skill/ directories
- for (const dir of await Config.directories()) {
- const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
- cwd: dir,
- absolute: true,
- include: "file",
- symlink: true,
- })
- for (const match of matches) {
- await addSkill(match)
- }
- }
-
- // Scan additional skill paths from config
- const config = await Config.get()
- for (const skillPath of config.skills?.paths ?? []) {
- const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
- const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
- if (!(await Filesystem.isDir(resolved))) {
- log.warn("skill path not found", { path: resolved })
- continue
- }
- const matches = await Glob.scan(SKILL_PATTERN, {
- cwd: resolved,
- absolute: true,
- include: "file",
- symlink: true,
- })
- for (const match of matches) {
- await addSkill(match)
- }
- }
-
- // Download and load skills from URLs
- for (const url of config.skills?.urls ?? []) {
- const list = await Effect.runPromise(discovery.pull(url))
- for (const dir of list) {
- skillDirs.add(dir)
- const matches = await Glob.scan(SKILL_PATTERN, {
- cwd: dir,
- absolute: true,
- include: "file",
- symlink: true,
- })
- for (const match of matches) {
- await addSkill(match)
- }
- }
- }
-
- log.info("init", { count: Object.keys(skills).length })
- })().catch((err) => {
- task = undefined
- throw err
- })
- return task
- }
-
- return SkillService.of({
- get: Effect.fn("SkillService.get")(function* (name: string) {
- yield* Effect.promise(() => ensureScanned())
- return skills[name]
- }),
- all: Effect.fn("SkillService.all")(function* () {
- yield* Effect.promise(() => ensureScanned())
- return Object.values(skills)
- }),
- dirs: Effect.fn("SkillService.dirs")(function* () {
- yield* Effect.promise(() => ensureScanned())
- return Array.from(skillDirs)
- }),
- available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
- yield* Effect.promise(() => ensureScanned())
- const list = Object.values(skills)
- if (!agent) return list
- return list.filter(
- (skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
- )
- }),
- })
- }),
- ).pipe(Layer.provide(DiscoveryService.defaultLayer))
-}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index a9489451c..9f0eef56b 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -9,20 +9,6 @@ import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
-const log = Log.create({ service: "snapshot" })
-const PRUNE = "7.days"
-
-// Common git config flags shared across snapshot operations
-const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
-const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
-const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
-
-interface GitResult {
- readonly code: ChildProcessSpawner.ExitCode
- readonly text: string
- readonly stderr: string
-}
-
export namespace Snapshot {
export const Patch = z.object({
hash: z.string(),
@@ -44,43 +30,47 @@ export namespace Snapshot {
})
export type FileDiff = z.infer<typeof FileDiff>
- // Promise facade — existing callers use these
- export function init() {
- void runPromiseInstance(SnapshotService.use((s) => s.init()))
- }
-
export async function cleanup() {
- return runPromiseInstance(SnapshotService.use((s) => s.cleanup()))
+ return runPromiseInstance(Service.use((svc) => svc.cleanup()))
}
export async function track() {
- return runPromiseInstance(SnapshotService.use((s) => s.track()))
+ return runPromiseInstance(Service.use((svc) => svc.track()))
}
export async function patch(hash: string) {
- return runPromiseInstance(SnapshotService.use((s) => s.patch(hash)))
+ return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
}
export async function restore(snapshot: string) {
- return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot)))
+ return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
- return runPromiseInstance(SnapshotService.use((s) => s.revert(patches)))
+ return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
}
export async function diff(hash: string) {
- return runPromiseInstance(SnapshotService.use((s) => s.diff(hash)))
+ return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
}
export async function diffFull(from: string, to: string) {
- return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to)))
+ return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
+ }
+
+ 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 namespace SnapshotService {
- export interface Service {
- readonly init: () => Effect.Effect<void>
+ export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
@@ -89,99 +79,92 @@ export namespace SnapshotService {
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
-}
-export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()(
- "@opencode/Snapshot",
-) {
- static readonly layer = Layer.effect(
- SnapshotService,
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
+
+ export const layer: Layer.Layer<
+ Service,
+ never,
+ InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
+ > = Layer.effect(
+ Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
- const fileSystem = yield* FileSystem.FileSystem
+ const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
- const { directory, worktree, project } = ctx
- const isGit = project.vcs === "git"
- const snapshotGit = path.join(Global.Path.data, "snapshot", project.id)
+ const directory = ctx.directory
+ const worktree = ctx.worktree
+ const project = ctx.project
+ const gitdir = path.join(Global.Path.data, "snapshot", project.id)
- const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd]
+ const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
- // Run git with nothrow semantics — always returns a result, never fails
- const git = (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Effect.Effect<GitResult> =>
- Effect.gen(function* () {
- const command = ChildProcess.make("git", args, {
+ 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(command)
+ 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 }
- }).pipe(
- Effect.scoped,
- Effect.catch((err) =>
- Effect.succeed({
- code: ChildProcessSpawner.ExitCode(1),
- text: "",
- stderr: String(err),
- }),
- ),
- )
-
- // FileSystem helpers — orDie converts PlatformError to defects
- const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie)
- const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie)
- const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie)
- const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed("")))
- const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
+ return { code, text, stderr } satisfies GitResult
+ },
+ Effect.scoped,
+ Effect.catch((err) =>
+ Effect.succeed({
+ code: ChildProcessSpawner.ExitCode(1),
+ text: "",
+ stderr: String(err),
+ }),
+ ),
+ )
- // --- internal Effect helpers ---
+ const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+ const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
+ const write = (file: string, text: string) => fs.writeFileString(file, text).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 isEnabled = Effect.gen(function* () {
- if (!isGit) return false
- const cfg = yield* Effect.promise(() => Config.get())
- return cfg.snapshot !== false
+ const enabled = Effect.fnUntraced(function* () {
+ if (project.vcs !== "git") return false
+ return (yield* Effect.promise(() => Config.get())).snapshot !== false
})
- const excludesPath = Effect.gen(function* () {
+ 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 undefined
- if (!(yield* exists(file))) return undefined
+ if (!file) return
+ if (!(yield* exists(file))) return
return file
})
- const syncExclude = Effect.gen(function* () {
- const file = yield* excludesPath
- const target = path.join(snapshotGit, "info", "exclude")
- yield* mkdir(path.join(snapshotGit, "info"))
+ const sync = Effect.fnUntraced(function* () {
+ const file = yield* excludes()
+ const target = path.join(gitdir, "info", "exclude")
+ yield* mkdir(path.join(gitdir, "info"))
if (!file) {
- yield* writeFile(target, "")
+ yield* write(target, "")
return
}
- const text = yield* readFile(file)
- yield* writeFile(target, text)
+ yield* write(target, yield* read(file))
})
- const add = Effect.gen(function* () {
- yield* syncExclude
- yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory })
+ const add = Effect.fnUntraced(function* () {
+ yield* sync()
+ yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
})
- // --- service methods ---
-
- const cleanup = Effect.fn("SnapshotService.cleanup")(function* () {
- if (!(yield* isEnabled)) return
- if (!(yield* exists(snapshotGit))) return
- const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
- 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,
@@ -189,58 +172,55 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
})
return
}
- log.info("cleanup", { prune: PRUNE })
+ log.info("cleanup", { prune })
})
- const track = Effect.fn("SnapshotService.track")(function* () {
- if (!(yield* isEnabled)) return undefined
- const existed = yield* exists(snapshotGit)
- yield* mkdir(snapshotGit)
+ const track = Effect.fn("Snapshot.track")(function* () {
+ if (!(yield* enabled())) return
+ const existed = yield* exists(gitdir)
+ yield* mkdir(gitdir)
if (!existed) {
yield* git(["init"], {
- env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree },
+ env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
})
- yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"])
- yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"])
- yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"])
- yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"])
+ 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(gitArgs(["write-tree"]), { cwd: directory })
+ yield* add()
+ const result = yield* git(args(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
- log.info("tracking", { hash, cwd: directory, git: snapshotGit })
+ log.info("tracking", { hash, cwd: directory, git: gitdir })
return hash
})
- const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) {
- yield* add
- const result = yield* git(
- [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
- { cwd: directory },
- )
-
+ 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: [] } as Snapshot.Patch
+ return { hash, files: [] }
}
-
return {
hash,
files: result.text
.trim()
.split("\n")
- .map((x: string) => x.trim())
+ .map((x) => x.trim())
.filter(Boolean)
- .map((x: string) => path.join(worktree, x).replaceAll("\\", "/")),
- } as Snapshot.Patch
+ .map((x) => path.join(worktree, x).replaceAll("\\", "/")),
+ }
})
- const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) {
+ const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
- const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree })
+ const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
- const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree })
+ const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
@@ -256,38 +236,33 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
})
})
- const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) {
+ 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([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], {
- cwd: worktree,
- })
+ const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree })
if (result.code !== 0) {
- const relativePath = path.relative(worktree, file)
- const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], {
- cwd: worktree,
- })
- if (checkTree.code === 0 && checkTree.text.trim()) {
+ 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* removeFile(file)
+ yield* remove(file)
}
}
- seen.add(file)
}
}
})
- const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) {
- yield* add
- const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], {
+ 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,
@@ -296,19 +271,15 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
})
return ""
}
-
return result.text.trim()
})
- const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) {
+ 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(
- [
- ...GIT_CFG_QUOTE,
- ...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
- ],
+ [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: directory },
)
@@ -316,64 +287,60 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
- const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
- status.set(file, kind)
+ status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
}
const numstat = yield* git(
- [...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
- { cwd: directory },
+ [...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 [additions, deletions, file] = line.split("\t")
- const isBinaryFile = additions === "-" && deletions === "-"
- const [before, after] = isBinaryFile
+ const [adds, dels, file] = line.split("\t")
+ if (!file) continue
+ const binary = adds === "-" && dels === "-"
+ const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[
- git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)),
- git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)),
+ 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 added = isBinaryFile ? 0 : parseInt(additions!)
- const deleted = isBinaryFile ? 0 : parseInt(deletions!)
+ const additions = binary ? 0 : parseInt(adds)
+ const deletions = binary ? 0 : parseInt(dels)
result.push({
- file: file!,
+ file,
before,
after,
- additions: Number.isFinite(added) ? added : 0,
- deletions: Number.isFinite(deleted) ? deleted : 0,
- status: status.get(file!) ?? "modified",
+ additions: Number.isFinite(additions) ? additions : 0,
+ deletions: Number.isFinite(deletions) ? deletions : 0,
+ status: status.get(file) ?? "modified",
})
}
+
return result
})
- // Start hourly cleanup fiber — scoped to instance lifetime
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 SnapshotService.of({
- init: Effect.fn("SnapshotService.init")(function* () {}),
- cleanup,
- track,
- patch,
- restore,
- revert,
- diff,
- diffFull,
- })
+ return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
}),
- ).pipe(
+ )
+
+ export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts
index 4d0ed8168..aa6c999fc 100644
--- a/packages/opencode/src/tool/truncate-effect.ts
+++ b/packages/opencode/src/tool/truncate-effect.ts
@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
-import { PermissionEffect } from "../permission/service"
+import { PermissionNext } from "../permission"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
@@ -27,10 +27,10 @@ export namespace TruncateEffect {
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
- return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
+ return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
}
- export interface Api {
+ export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
@@ -39,14 +39,14 @@ export namespace TruncateEffect {
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
- export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {}
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
- const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () {
+ 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_"))),
@@ -58,7 +58,7 @@ export namespace TruncateEffect {
}
})
- const output = Effect.fn("TruncateEffect.output")(function* (
+ const output = Effect.fn("Truncate.output")(function* (
text: string,
options: Options = {},
agent?: Agent.Info,
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index d7239bfbf..94cd9eb94 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 { AccountService } from "../../src/account/service"
+import { AccountEffect } from "../../src/account/effect"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect"
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) =>
- AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
+ AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb(
@@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
}),
)
- const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
+ const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]],
@@ -115,7 +115,7 @@ it.effect("token refresh persists the new token", () =>
),
)
- const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
+ const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -158,7 +158,9 @@ it.effect("config sends the selected org header", () =>
}),
)
- const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
+ const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(
+ Effect.provide(live(client)),
+ )
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(seen).toEqual({
@@ -196,7 +198,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
- const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+ const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts
index a2de61733..8a3d30d31 100644
--- a/packages/opencode/test/file/watcher.test.ts
+++ b/packages/opencode/test/file/watcher.test.ts
@@ -5,7 +5,7 @@ import path from "path"
import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
-import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
+import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
@@ -19,13 +19,12 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
-/** Run `body` with a live FileWatcherService. */
+/** Run `body` with a live FileWatcher service. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return withServices(
directory,
- FileWatcherService.layer,
+ FileWatcher.layer,
async (rt) => {
- await rt.runPromise(FileWatcherService.use((s) => s.init()))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
@@ -138,7 +137,7 @@ function ready(directory: string) {
// Tests
// ---------------------------------------------------------------------------
-describeWatcher("FileWatcherService", () => {
+describeWatcher("FileWatcher", () => {
afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => {
diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts
index 610850d47..2718e125d 100644
--- a/packages/opencode/test/format/format.test.ts
+++ b/packages/opencode/test/format/format.test.ts
@@ -1,17 +1,18 @@
+import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
-import { FormatService } from "../../src/format"
+import { Format } from "../../src/format"
import { Instance } from "../../src/project/instance"
-describe("FormatService", () => {
+describe("Format", () => {
afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir()
- await withServices(tmp.path, FormatService.layer, async (rt) => {
- const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
+ await withServices(tmp.path, Format.layer, async (rt) => {
+ const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0)
@@ -32,8 +33,8 @@ describe("FormatService", () => {
config: { formatter: false },
})
- await withServices(tmp.path, FormatService.layer, async (rt) => {
- const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
+ await withServices(tmp.path, Format.layer, async (rt) => {
+ const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
expect(statuses).toEqual([])
})
})
@@ -47,18 +48,18 @@ describe("FormatService", () => {
},
})
- await withServices(tmp.path, FormatService.layer, async (rt) => {
- const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
+ await withServices(tmp.path, Format.layer, async (rt) => {
+ const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeUndefined()
})
})
- test("init() completes without error", async () => {
+ test("service initializes without error", async () => {
await using tmp = await tmpdir()
- await withServices(tmp.path, FormatService.layer, async (rt) => {
- await rt.runPromise(FormatService.use((s) => s.init()))
+ await withServices(tmp.path, Format.layer, async (rt) => {
+ await rt.runPromise(Format.Service.use(() => Effect.void))
})
})
})
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 6fa782b05..2a6b6e0ba 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission"
-import * as S from "../../src/permission/service"
+import { PermissionNext as S } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
- S.PermissionEffect.Service.use((svc) =>
+ S.Service.use((svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts
index d8f8ea455..0095ff387 100644
--- a/packages/opencode/test/plugin/auth-override.test.ts
+++ b/packages/opencode/test/plugin/auth-override.test.ts
@@ -4,6 +4,7 @@ import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
+import { ProviderID } from "../../src/provider/schema"
describe("plugin.auth-override", () => {
test("user plugin overrides built-in github-copilot auth", async () => {
@@ -34,7 +35,7 @@ describe("plugin.auth-override", () => {
directory: tmp.path,
fn: async () => {
const methods = await ProviderAuth.methods()
- const copilot = methods["github-copilot"]
+ const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts
index b5100585f..90f445ed7 100644
--- a/packages/opencode/test/project/vcs.test.ts
+++ b/packages/opencode/test/project/vcs.test.ts
@@ -2,13 +2,13 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
-import { Layer, ManagedRuntime } from "effect"
+import { Effect, Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
-import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
+import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global"
-import { Vcs, VcsService } from "../../src/project/vcs"
+import { Vcs } from "../../src/project/vcs"
// Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -19,15 +19,15 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
function withVcs(
directory: string,
- body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>,
+ body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
) {
return withServices(
directory,
- Layer.merge(FileWatcherService.layer, VcsService.layer),
+ Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => {
- await rt.runPromise(FileWatcherService.use((s) => s.init()))
- await rt.runPromise(VcsService.use((s) => s.init()))
- await Bun.sleep(200)
+ await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
+ await rt.runPromise(Vcs.Service.use(() => Effect.void))
+ await Bun.sleep(500)
await body(rt)
},
{ provide: [watcherConfigLayer] },
@@ -36,10 +36,14 @@ function withVcs(
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
-/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */
-function nextBranchUpdate(directory: string, timeout = 5000) {
+/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
+function nextBranchUpdate(directory: string, timeout = 10_000) {
return new Promise<string | undefined>((resolve, reject) => {
+ let settled = false
+
const timer = setTimeout(() => {
+ if (settled) return
+ settled = true
GlobalBus.off("event", on)
reject(new Error("timed out waiting for BranchUpdated event"))
}, timeout)
@@ -47,6 +51,8 @@ function nextBranchUpdate(directory: string, timeout = 5000) {
function on(evt: BranchEvent) {
if (evt.directory !== directory) return
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
+ if (settled) return
+ settled = true
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties.branch)
@@ -67,7 +73,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async (rt) => {
- const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
+ const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
@@ -77,7 +83,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir()
await withVcs(tmp.path, async (rt) => {
- const branch = await rt.runPromise(VcsService.use((s) => s.branch()))
+ const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(branch).toBeUndefined()
})
})
@@ -110,7 +116,7 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending
- const current = await rt.runPromise(VcsService.use((s) => s.branch()))
+ const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(current).toBe(branch)
})
})
diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts
index cc1ac0cbc..839ba4607 100644
--- a/packages/opencode/test/server/project-init-git.test.ts
+++ b/packages/opencode/test/server/project-init-git.test.ts
@@ -68,6 +68,7 @@ describe("project.initGit endpoint", () => {
},
})
} finally {
+ await Instance.disposeAll()
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
@@ -111,7 +112,9 @@ describe("project.initGit endpoint", () => {
vcs: "git",
worktree: tmp.path,
})
+
} finally {
+ await Instance.disposeAll()
reloadSpy.mockRestore()
GlobalBus.off("event", fn)
}
diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts
index 5cbb3ada0..de356ef15 100644
--- a/packages/opencode/test/skill/discovery.test.ts
+++ b/packages/opencode/test/skill/discovery.test.ts
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect"
-import { DiscoveryService } from "../../src/skill/discovery"
+import { Discovery } from "../../src/skill/discovery"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
import { rm } from "fs/promises"
@@ -48,7 +48,7 @@ afterAll(async () => {
describe("Discovery.pull", () => {
const pull = (url: string) =>
- Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer)))
+ Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL)
diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts
index 1804ab5c2..203050287 100644
--- a/packages/opencode/test/snapshot/snapshot.test.ts
+++ b/packages/opencode/test/snapshot/snapshot.test.ts
@@ -1178,3 +1178,37 @@ test("diffFull with whitespace changes", async () => {
},
})
})
+
+test("revert with overlapping files across patches uses first patch hash", async () => {
+ await using tmp = await bootstrap()
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Write initial content and snapshot
+ await Filesystem.write(`${tmp.path}/shared.txt`, "v1")
+ const snap1 = await Snapshot.track()
+ expect(snap1).toBeTruthy()
+
+ // Modify and snapshot again
+ await Filesystem.write(`${tmp.path}/shared.txt`, "v2")
+ const snap2 = await Snapshot.track()
+ expect(snap2).toBeTruthy()
+
+ // Modify once more so both patches include shared.txt
+ await Filesystem.write(`${tmp.path}/shared.txt`, "v3")
+
+ const patch1 = await Snapshot.patch(snap1!)
+ const patch2 = await Snapshot.patch(snap2!)
+
+ // Both patches should include shared.txt
+ expect(patch1.files).toContain(fwd(tmp.path, "shared.txt"))
+ expect(patch2.files).toContain(fwd(tmp.path, "shared.txt"))
+
+ // Revert with patch1 first — should use snap1's hash (restoring "v1")
+ await Snapshot.revert([patch1, patch2])
+
+ const content = await fs.readFile(`${tmp.path}/shared.txt`, "utf-8")
+ expect(content).toBe("v1")
+ },
+ })
+})