Compare commits

...

49 Commits

Author SHA1 Message Date
opencode-agent[bot]
27190635ea chore: update nix node_modules hashes 2026-04-11 04:45:55 +00:00
opencode-agent[bot]
2e340d976f chore: generate 2026-04-11 03:53:48 +00:00
Kit Langton
fe4dfb9f6f refactor(git): remove runtime facade wrappers (#21982) 2026-04-10 23:52:48 -04:00
Kit Langton
5e3dc80999 refactor: collapse command facade (#21981) 2026-04-10 23:52:12 -04:00
Kit Langton
d84cc33742 refactor(plugin): return Effect from ToolContext.ask (#21986) 2026-04-10 23:50:50 -04:00
opencode-agent[bot]
c92c462148 chore: update nix node_modules hashes 2026-04-11 03:39:49 +00:00
Kit Langton
9ca06e0336 docs(effect): mark SessionTodo migrated (#21987) 2026-04-10 23:35:50 -04:00
opencode-agent[bot]
3b523b32f5 chore: generate 2026-04-11 03:28:30 +00:00
Kit Langton
ba3600a515 refactor(session): remove dead updatePartDelta facade (#21985) 2026-04-10 23:27:30 -04:00
Kit Langton
03ce2e5288 refactor(installation): drop facade runtime wrappers (#21984) 2026-04-10 23:26:16 -04:00
Kit Langton
87e23abb10 refactor: remove ProviderAuth facade (#21983) 2026-04-10 23:25:43 -04:00
opencode-agent[bot]
2868000c20 chore: generate 2026-04-11 03:19:50 +00:00
Kit Langton
f38f415bf0 refactor: collapse Format facade (#21980) 2026-04-10 23:18:54 -04:00
Kit Langton
4341ab838e refactor(tool): use Session.Service directly in TaskTool (#21975) 2026-04-10 23:18:30 -04:00
Kit Langton
cd004cf0b2 refactor(session): eliminate Effect.promise roundtrips for sync MessageV2.stream (#21973) 2026-04-10 23:18:13 -04:00
opencode-agent[bot]
19ae8c88b0 chore: generate 2026-04-11 03:13:03 +00:00
Kit Langton
3dd09147c2 refactor(tool): Tool.Context.metadata returns Effect (#21972) 2026-04-10 23:12:04 -04:00
Kit Langton
9581bf0670 refactor(effect): upgrade opencode to beta.46 context APIs (#21977) 2026-04-10 23:06:28 -04:00
Kit Langton
af8aff3788 refactor: make TaskPromptOps effectful (#21971) 2026-04-10 22:57:47 -04:00
opencode-agent[bot]
2a8a59ded9 chore: generate 2026-04-11 02:56:03 +00:00
Kit Langton
5917ac2162 fix: provide EffectLogger.layer to bare Effect.runPromise/runFork calls (#21974) 2026-04-10 22:55:08 -04:00
Brendan Allan
b6af4d0dc6 refactor(config): pass instance context to containsPath (#21882) 2026-04-11 10:43:40 +08:00
opencode-agent[bot]
577139c626 chore: generate 2026-04-11 02:36:59 +00:00
Kit Langton
c5fb6281f0 refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961) 2026-04-10 22:36:02 -04:00
Kit Langton
f99812443c refactor: destroy SessionStatus facade (#21968) 2026-04-10 22:16:53 -04:00
opencode-agent[bot]
b898c6d0ea chore: generate 2026-04-11 02:04:02 +00:00
Kit Langton
9e7045eaec refactor: destroy ShareNext facade (#21965) 2026-04-10 22:03:06 -04:00
Kit Langton
a17ac02061 refactor: extract LSP diagnostic report formatter (#21964) 2026-04-10 22:00:56 -04:00
opencode-agent[bot]
57f9397677 chore: generate 2026-04-11 01:48:25 +00:00
Kit Langton
a4c686025c refactor: destroy Todo facade (#21962) 2026-04-10 21:47:28 -04:00
Kit Langton
face879100 fix: disable default Effect console logger (#21963) 2026-04-10 21:27:24 -04:00
opencode-agent[bot]
605559b165 chore: generate 2026-04-11 01:22:01 +00:00
Kit Langton
5cd4c6eb22 refactor: destroy Storage facades (#21956) 2026-04-10 21:21:02 -04:00
Kit Langton
40358d60a0 refactor: add Effect logger for motel observability (#21954) 2026-04-10 21:10:58 -04:00
Aiden Cline
96c1c0363d chore: rm unnecessary test (now we use effect) and the test is flaky (#21959) 2026-04-10 19:59:33 -05:00
Aiden Cline
33819932ec tweak: rm processor .trim calls (#21958) 2026-04-10 19:47:08 -05:00
Kit Langton
5d6fe01465 convert skill tool to Tool.defineEffect (#21936) 2026-04-10 19:49:53 -04:00
Kit Langton
cf27a73397 feat: add AppRuntime for unified service composition (#21953) 2026-04-10 19:46:52 -04:00
opencode-agent[bot]
f2c492a8e6 chore: generate 2026-04-10 23:43:20 +00:00
Kit Langton
0556774097 refactor(tool): convert apply_patch to Tool.defineEffect (#21938) 2026-04-10 19:42:14 -04:00
Kit Langton
d9d5a0615e refactor: break SessionPrompt/TaskTool cycle via ctx injection (#21948) 2026-04-10 19:36:13 -04:00
Kit Langton
d72ddd71fa refactor(tool): convert grep tool to Tool.defineEffect (#21937) 2026-04-10 19:20:00 -04:00
opencode-agent[bot]
fb26308bc7 chore: generate 2026-04-10 21:12:22 +00:00
Kit Langton
b41fa8e318 refactor: convert edit tool to Tool.defineEffect (#21904) 2026-04-10 17:10:28 -04:00
opencode-agent[bot]
57b2e64345 chore: generate 2026-04-10 21:01:33 +00:00
Frank
346b3e1b8d zen: nemotron doc 2026-04-10 16:58:38 -04:00
Kit Langton
b139bc2ef3 refactor(tool): convert write tool to Tool.defineEffect (#21901) 2026-04-10 16:57:12 -04:00
opencode-agent[bot]
378b8ca241 chore: generate 2026-04-10 19:40:10 +00:00
Kit Langton
f63bdc8e08 convert list tool to Tool.defineEffect (#21899) 2026-04-10 15:38:52 -04:00
155 changed files with 3209 additions and 3082 deletions

View File

@@ -413,7 +413,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -450,6 +450,7 @@
"version": "1.4.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -641,7 +642,7 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.43",
"@effect/platform-node": "4.0.0-beta.46",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10",
@@ -668,7 +669,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43",
"effect": "4.0.0-beta.46",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1025,11 +1026,11 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
"@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.46", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.46", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2889,7 +2890,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5509,6 +5510,10 @@
"@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -6435,6 +6440,10 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-285KZ7rZLRoc6XqCZRHc25NE+mmpGh/BVeMpv8aPQtQ=",
"aarch64-linux": "sha256-qIwmY4TP4CI7R7G6A5OMYRrorVNXjkg25tTtVpIHm2o=",
"aarch64-darwin": "sha256-RwvnZQhdYZ0u7h7evyfxuPLHHX9eO/jXTAxIFc8B+IE=",
"x86_64-darwin": "sha256-vVj40al+TEeMpbe5XG2GmJEpN+eQAvtr9W0T98l5PBE="
"x86_64-linux": "sha256-gFbo3B6TFAmin2marXlwUyfchTX6ogsaUFEzBIl4zaI=",
"aarch64-linux": "sha256-HUKL7zBVtb1KPaoAgfSfAzjDoAPRUe2WNFHDrsoqEF8=",
"aarch64-darwin": "sha256-qWPRkuVA3nDEEaVZ0Ex4sYsFFarSRJSyOn+KJm1D3U0=",
"x86_64-darwin": "sha256-FxhOYMXkxjn/9xQPeVX/gfQT/KjHT4wIBqzVDZuYlos="
}
}

View File

@@ -26,7 +26,7 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.43",
"@effect/platform-node": "4.0.0-beta.46",
"@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
@@ -47,7 +47,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43",
"effect": "4.0.0-beta.46",
"ai": "6.0.149",
"cross-spawn": "7.0.6",
"hono": "4.10.7",

View File

@@ -43,7 +43,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",

View File

@@ -23,7 +23,7 @@ export namespace Foo {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
@@ -219,11 +219,11 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Instruction``session/instruction.ts`
- [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
Still open:
- [ ] `SessionTodo``session/todo.ts`
- [ ] `ShareNext``share/share-next.ts`
- [x] `SessionTodo``session/todo.ts`
- [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts`
@@ -308,3 +308,35 @@ Current raw fs users that will convert during tool migration:
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
For each service, the migration is roughly:
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
### Pitfalls
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
- **`Effect.tryPromise``yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
### Migration log
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.

View File

@@ -1,4 +1,4 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import {
FetchHttpClient,
HttpClient,
@@ -181,7 +181,7 @@ export namespace Account {
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,

View File

@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
@@ -38,7 +38,7 @@ export namespace AccountRepo {
}
}
export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {

View File

@@ -1,42 +1,22 @@
import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export const AccountID = Schema.String.pipe(Schema.brand("AccountID"))
export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(
Schema.brand("OrgID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const RefreshToken = Schema.String.pipe(
Schema.brand("RefreshToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export const DeviceCode = Schema.String.pipe(
Schema.brand("DeviceCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe(
Schema.brand("UserCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({

View File

@@ -19,7 +19,7 @@ import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -67,7 +67,7 @@ export namespace Agent {
type State = Omit<Interface, "generate">
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,5 +1,5 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
@@ -49,7 +49,7 @@ export namespace Auth {
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,5 +1,6 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Log } from "../util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
@@ -41,7 +42,7 @@ export namespace Bus {
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
@@ -146,7 +147,7 @@ export namespace Bus {
return () => {
log.info("unsubscribing", { type })
Effect.runFork(Scope.close(scope, Exit.void))
Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer)))
}
})
}
@@ -169,6 +170,8 @@ export namespace Bus {
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,

View File

@@ -1,5 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { Session } from "../../../session"
@@ -157,14 +158,16 @@ async function createToolContext(agent: Agent.Info) {
agent: agent.name,
abort: new AbortController().signal,
messages: [],
metadata: () => {},
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
metadata: () => Effect.void,
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
}
}
}
})
},
}
}

View File

@@ -29,6 +29,7 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
@@ -258,7 +259,9 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -497,20 +500,21 @@ export const GithubRunCommand = cmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await Git.run(args, { cwd: Instance.worktree })
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await Git.run(args, { cwd: Instance.worktree })
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)

View File

@@ -10,6 +10,7 @@ import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
import { Filesystem } from "../../util/filesystem"
import { AppRuntime } from "@/effect/app-runtime"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
@@ -100,7 +101,7 @@ export const ImportCommand = cmd({
if (isUrl) {
const slug = parseShareUrl(args.file)
if (!slug) {
const baseUrl = await ShareNext.url()
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL)
return
@@ -108,7 +109,7 @@ export const ImportCommand = cmd({
const parsed = new URL(args.file)
const baseUrl = parsed.origin
const req = await ShareNext.request()
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug)

View File

@@ -1,5 +1,6 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { Process } from "@/util/process"
@@ -67,19 +68,29 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
const remotes = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
if (!remotes.split("\n").includes(remoteName)) {
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
}),
),
)
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
await AppRuntime.runPromise(
Git.Service.use((git) =>
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
}),
),
)
}
// Check for opencode session link in PR body

View File

@@ -1,6 +1,7 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
import { Global } from "../../global"
import fs from "fs/promises"
@@ -57,7 +58,7 @@ export const UninstallCommand = {
UI.empty()
prompts.intro("Uninstall OpenCode")
const method = await Installation.method()
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method)

View File

@@ -1,6 +1,7 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation"
export const UpgradeCommand = {
@@ -24,7 +25,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Upgrade")
const detectedMethod = await Installation.method()
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -42,7 +43,9 @@ export const UpgradeCommand = {
}
}
prompts.log.info("Using method: " + method)
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
const target = args.target
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -53,7 +56,9 @@ export const UpgradeCommand = {
prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner()
spinner.start("Upgrading...")
const err = await Installation.upgrade(method, target).catch((err) => err)
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch(
(err) => err,
)
if (err) {
spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) {

View File

@@ -1,12 +1,13 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.getGlobal()
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@@ -25,7 +26,7 @@ export async function upgrade() {
}
if (method === "unknown") return
await Installation.upgrade(method, latest)
await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest)))
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {})
}

View File

@@ -1,8 +1,9 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import z from "zod"
import { Config } from "../config/config"
import { MCP } from "../mcp"
@@ -70,7 +71,7 @@ export namespace Command {
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
@@ -79,7 +80,7 @@ export namespace Command {
const mcp = yield* MCP.Service
const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx) {
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
const cfg = yield* config.get()
const commands: Record<string, Info> = {}
@@ -140,6 +141,7 @@ export namespace Command {
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
),
Effect.provide(EffectLogger.layer),
),
)
},
@@ -186,10 +188,4 @@ export namespace Command {
Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
}

View File

@@ -37,10 +37,11 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Duration, Effect, Layer, Option, Context } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
import { InstanceRef } from "@/effect/instance-ref"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -1126,7 +1127,7 @@ export namespace Config {
readonly waitForDependencies: () => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
@@ -1327,27 +1328,31 @@ export namespace Config {
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const scope = (source: string): PluginScope => {
const scope = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (Instance.containsPath(source)) return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
}
})
const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
const track = Effect.fnUntraced(function* (
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
if (!list?.length) return
const hit = kind ?? scope(source)
const hit = kind ?? (yield* scope(source))
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
}
})
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
track(source, next.plugin, kind)
return track(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
@@ -1367,16 +1372,16 @@ export namespace Config {
dir: path.dirname(source),
source,
})
merge(source, next, "global")
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
merge(Global.Path.config, global, "global")
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
@@ -1384,7 +1389,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
merge(file, yield* loadFile(file), "local")
yield* merge(file, yield* loadFile(file), "local")
}
}
@@ -1405,7 +1410,7 @@ export namespace Config {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
merge(source, yield* loadFile(source))
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -1424,7 +1429,7 @@ export namespace Config {
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir))
track(dir, list)
yield* track(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
@@ -1433,7 +1438,7 @@ export namespace Config {
dir: ctx.directory,
source,
})
merge(source, next, "local")
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -1462,7 +1467,7 @@ export namespace Config {
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
merge(source, next, "global")
yield* merge(source, next, "global")
}
}).pipe(
Effect.catch((err) => {
@@ -1477,7 +1482,7 @@ export namespace Config {
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
merge(source, yield* loadFile(source), "global")
yield* merge(source, yield* loadFile(source), "global")
}
}

View File

@@ -10,8 +10,7 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
})),
)

View File

@@ -1,11 +1,11 @@
import { Context } from "../util/context"
import { LocalContext } from "../util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: string
}
const context = Context.create<WorkspaceContext>("instance")
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {

View File

@@ -0,0 +1,100 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Observability } from "./oltp"
import { AppFileSystem } from "@/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
import { Session } from "@/session"
import { SessionStatus } from "@/session/status"
import { SessionRunState } from "@/session/run-state"
import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
Skill.defaultLayer,
Discovery.defaultLayer,
Question.defaultLayer,
Permission.defaultLayer,
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
MCP.defaultLayer,
McpAuth.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })

View File

@@ -0,0 +1,9 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })

View File

@@ -402,6 +402,7 @@ export const make = Effect.gen(function* () {
const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr)
let ref = true
return makeHandle({
pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin),
@@ -432,6 +433,18 @@ export const make = Effect.gen(function* () {
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
})
},
unref: Effect.sync(() => {
if (ref) {
proc.unref()
ref = false
}
return Effect.sync(() => {
if (!ref) {
proc.ref()
ref = true
}
})
}),
})
}
case "PipedCommand": {

View File

@@ -1,10 +1,10 @@
import { ServiceMap } from "effect"
import { Context } from "effect"
import type { InstanceContext } from "@/project/instance"
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})

View File

@@ -1,6 +1,7 @@
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -17,10 +18,10 @@ export namespace InstanceState {
try {
return Instance.bind(fn)
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
if (!(err instanceof LocalContext.NotFound)) throw err
}
const fiber = Fiber.getCurrent()
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined
if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
}
@@ -47,7 +48,9 @@ export namespace InstanceState {
}),
})
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
const off = registerDisposer((directory) =>
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
)
yield* Effect.addFinalizer(() => Effect.sync(off))
return {

View File

@@ -0,0 +1,67 @@
import { Cause, Effect, Logger, References } from "effect"
import { Log } from "@/util/log"
export namespace EffectLogger {
type Fields = Record<string, unknown>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
}

View File

@@ -1,34 +1,41 @@
import { Layer } from "effect"
import { Duration, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability {
export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT
? Layer.empty
: Otlp.layerJson({
baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
loggerMergeWithExisting: false,
resource: {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
const resource = {
serviceName: "opencode",
serviceVersion: VERSION,
attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
},
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, value] = x.split("=")
acc[key] = value
return acc
},
{} as Record<string, string>,
)
: undefined,
}).pipe(Layer.provide(FetchHttpClient.layer))
{} as Record<string, string>,
)
: undefined
export const layer = !base
? EffectLogger.layer
: Otlp.layerJson({
baseUrl: base,
loggerExportInterval: Duration.seconds(1),
loggerMergeWithExisting: true,
resource,
headers,
}).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
}

View File

@@ -1,7 +1,7 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { Context } from "@/util/context"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./oltp"
import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -14,12 +14,12 @@ export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A
const workspaceID = WorkspaceContext.workspaceID
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
if (!(err instanceof LocalContext.NotFound)) throw err
}
return effect
}
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))

View File

@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
@@ -337,7 +337,7 @@ export namespace File {
}) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect(
Service,

View File

@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
@@ -291,7 +291,7 @@ export namespace Ripgrep {
}) => Stream.Stream<string, PlatformError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,

View File

@@ -1,4 +1,4 @@
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
@@ -34,10 +34,10 @@ export namespace FileTime {
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>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
@@ -103,8 +103,8 @@ export namespace FileTime {
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
@@ -128,6 +128,6 @@ export namespace FileTime {
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, fn))
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
}
}

View File

@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
import { Cause, Effect, Layer, Scope, Context } from "effect"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
@@ -65,7 +65,7 @@ export namespace FileWatcher {
readonly init: () => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,

View File

@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "../util/glob"
@@ -36,7 +36,7 @@ export namespace AppFileSystem {
readonly globMatch: (pattern: string, filepath: string) => boolean
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,8 +1,7 @@
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
@@ -31,7 +30,7 @@ export namespace Format {
readonly file: (filepath: string) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect(
Service,
@@ -193,18 +192,4 @@ export namespace Format {
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() {
return runPromise((s) => s.status())
}
export async function file(filepath: string) {
return runPromise((s) => s.file(filepath))
}
}

View File

@@ -1,7 +1,6 @@
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { Effect, Layer, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { makeRuntime } from "@/effect/run-service"
export namespace Git {
const cfg = [
@@ -80,7 +79,7 @@ export namespace Git {
return "modified"
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
export const layer = Layer.effect(
Service,
@@ -258,14 +257,4 @@ export namespace Git {
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function run(args: string[], opts: Options) {
return runPromise((git) => git.run(args, opts))
}
export async function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
}

View File

@@ -1,7 +1,6 @@
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
@@ -91,7 +90,7 @@ export namespace Installation {
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {}
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
Layer.effect(
@@ -338,18 +337,4 @@ export namespace Installation {
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function method(): Promise<Method> {
return runPromise((svc) => svc.method())
}
export async function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
export async function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
}
}

View File

@@ -11,7 +11,7 @@ import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -156,7 +156,7 @@ export namespace LSP {
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LSP") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
export const layer = Layer.effect(
Service,
@@ -540,6 +540,8 @@ export namespace LSP {
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic {
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
@@ -554,5 +556,14 @@ export namespace LSP {
return `${severity} [${line}:${col}] ${diagnostic.message}`
}
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
}

View File

@@ -1,7 +1,7 @@
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
@@ -49,7 +49,7 @@ export namespace McpAuth {
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {}
export const layer = Layer.effect(
Service,
@@ -141,7 +141,7 @@ export namespace McpAuth {
}),
)
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -24,7 +24,8 @@ import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -239,7 +240,7 @@ export namespace MCP {
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/MCP") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
export const layer = Layer.effect(
Service,
@@ -469,12 +470,14 @@ export namespace MCP {
log.info("tools list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await Effect.runPromise(defs(name, client, timeout))
const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer)))
if (!listed) return
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
s.defs[name] = listed
await Effect.runPromise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
await Effect.runPromise(
bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)),
)
})
}

View File

@@ -10,7 +10,7 @@ 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 { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
@@ -135,7 +135,7 @@ export namespace Permission {
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
export const layer = Layer.effect(
Service,

View File

@@ -5,12 +5,8 @@ import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema"
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
static make(id: string): PermissionID {
return this.makeUnsafe(id)
}
static ascending(id?: string): PermissionID {
return this.makeUnsafe(Identifier.ascending("permission", id))
return this.make(Identifier.ascending("permission", id))
}
static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>

View File

@@ -11,7 +11,8 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
@@ -44,7 +45,7 @@ export namespace Plugin {
readonly init: () => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [
@@ -83,7 +84,11 @@ export namespace Plugin {
}
function publishPluginError(bus: Bus.Interface, message: string) {
Effect.runFork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
Effect.runFork(
bus
.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
.pipe(Effect.provide(EffectLogger.layer)),
)
}
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {

View File

@@ -10,13 +10,14 @@ import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { ShareNext } from "@/share/share-next"
export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
ShareNext.init()
Format.init()
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
await LSP.init()
File.init()
FileWatcher.init()

View File

@@ -3,7 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { LocalContext } from "../util/local-context"
import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state"
@@ -14,7 +14,7 @@ export interface InstanceContext {
project: Project.Info
}
const context = Context.create<InstanceContext>("instance")
const context = LocalContext.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const disposal = {
@@ -90,12 +90,13 @@ export const Instance = {
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
containsPath(filepath: string, ctx?: InstanceContext) {
const instance = ctx ?? Instance
if (Filesystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
return Filesystem.contains(instance.worktree, filepath)
},
/**
* Captures the current instance ALS context and returns a wrapper that

View File

@@ -8,7 +8,7 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRuntime } from "@/effect/run-service"
@@ -100,7 +100,7 @@ export namespace Project {
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
type GitResult = { code: number; text: string; stderr: string }

View File

@@ -9,8 +9,7 @@ export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({
global: schema.makeUnsafe("global"),
make: (id: string) => schema.makeUnsafe(id),
global: schema.make("global"),
zod: z.string().pipe(z.custom<ProjectID>()),
})),
)

View File

@@ -1,4 +1,4 @@
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { Effect, Layer, Context, Stream } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
@@ -151,7 +151,7 @@ export namespace Vcs {
root: Git.Base | undefined
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service,
@@ -226,7 +226,7 @@ export namespace Vcs {
}),
)
const defaultLayer = layer.pipe(
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),

View File

@@ -2,10 +2,9 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect"
import z from "zod"
export namespace ProviderAuth {
@@ -109,7 +108,7 @@ export namespace ProviderAuth {
pending: Map<ProviderID, AuthOAuthResult>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
Service,
@@ -232,22 +231,4 @@ export namespace ProviderAuth {
export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function methods() {
return runPromise((svc) => svc.methods())
}
export async function authorize(input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}): Promise<Authorization | undefined> {
return runPromise((svc) => svc.authorize(input))
}
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
return runPromise((svc) => svc.callback(input))
}
}

View File

@@ -19,7 +19,8 @@ import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -924,7 +925,7 @@ export namespace Provider {
varsLoaders: Record<string, CustomVarsLoader>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
const result: Model["cost"] = {
@@ -1215,7 +1216,8 @@ export namespace Provider {
const options = yield* Effect.promise(() =>
plugin.auth!.loader!(
() => Effect.runPromise(auth.get(providerID).pipe(Effect.orDie)) as any,
() =>
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
database[plugin.auth!.provider],
),
)

View File

@@ -9,20 +9,19 @@ export type ProviderID = typeof providerIdSchema.Type
export const ProviderID = providerIdSchema.pipe(
withStatics((schema: typeof providerIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ProviderID>()),
// Well-known providers
opencode: schema.makeUnsafe("opencode"),
anthropic: schema.makeUnsafe("anthropic"),
openai: schema.makeUnsafe("openai"),
google: schema.makeUnsafe("google"),
googleVertex: schema.makeUnsafe("google-vertex"),
githubCopilot: schema.makeUnsafe("github-copilot"),
amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
azure: schema.makeUnsafe("azure"),
openrouter: schema.makeUnsafe("openrouter"),
mistral: schema.makeUnsafe("mistral"),
gitlab: schema.makeUnsafe("gitlab"),
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
@@ -32,7 +31,6 @@ export type ModelID = typeof modelIdSchema.Type
export const ModelID = modelIdSchema.pipe(
withStatics((schema: typeof modelIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ModelID>()),
})),
)

View File

@@ -10,7 +10,8 @@ import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
export namespace Pty {
const log = Log.create({ service: "pty" })
@@ -112,7 +113,7 @@ export namespace Pty {
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Pty") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = Layer.effect(
Service,
@@ -256,8 +257,8 @@ export namespace Pty {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Effect.runFork(bus.publish(Event.Exited, { id, exitCode }))
Effect.runFork(remove(id))
Effect.runFork(bus.publish(Event.Exited, { id, exitCode }).pipe(Effect.provide(EffectLogger.layer)))
Effect.runFork(remove(id).pipe(Effect.provide(EffectLogger.layer)))
}),
)
yield* bus.publish(Event.Created, { info })
@@ -359,7 +360,7 @@ export namespace Pty {
}),
)
const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -10,8 +10,7 @@ export type PtyID = typeof ptyIdSchema.Type
export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("pty", id)),
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
zod: Identifier.schema("pty").pipe(z.custom<PtyID>()),
})),
)

View File

@@ -1,4 +1,4 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
@@ -104,7 +104,7 @@ export namespace Question {
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,

View File

@@ -5,12 +5,8 @@ import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema"
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
static make(id: string): QuestionID {
return this.makeUnsafe(id)
}
static ascending(id?: string): QuestionID {
return this.makeUnsafe(Identifier.ascending("question", id))
return this.make(Identifier.ascending("question", id))
}
static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID>

View File

@@ -30,6 +30,7 @@ import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { errorHandler } from "./middleware"
import { getMimeType } from "hono/utils/mime"
import { AppRuntime } from "@/effect/app-runtime"
const log = Log.create({ service: "server" })
@@ -190,7 +191,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
},
}),
async (c) => {
const commands = await Command.list()
const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
return c.json(commands)
},
)
@@ -277,7 +278,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
},
}),
async (c) => {
return c.json(await Format.status())
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
},
)
.all("/*", async (c) => {

View File

@@ -1,10 +1,12 @@
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Effect } from "effect"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
@@ -290,25 +292,41 @@ export const GlobalRoutes = lazy(() =>
}),
),
async (c) => {
const method = await Installation.method()
if (method === "unknown") {
return c.json({ success: false, error: "Unknown installation method" }, 400)
const result = await AppRuntime.runPromise(
Installation.Service.use((svc) =>
Effect.gen(function* () {
const method = yield* svc.method()
if (method === "unknown") {
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
}
const target = c.req.valid("json").target || (yield* svc.latest(method))
const result = yield* Effect.catch(
svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
(err) =>
Effect.succeed({
success: false as const,
status: 500 as const,
error: err instanceof Error ? err.message : String(err),
}),
)
if (!result.success) return result
return { ...result, status: 200 as const }
}),
),
)
if (!result.success) {
return c.json({ success: false, error: result.error }, result.status)
}
const target = c.req.valid("json").target || (await Installation.latest(method))
const result = await Installation.upgrade(method, target)
.then(() => ({ success: true as const, version: target }))
.catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
if (result.success) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return c.json(result)
}
return c.json(result, 500)
const target = result.version
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return c.json({ success: true, version: target })
},
),
)

View File

@@ -6,6 +6,7 @@ import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"
import { ProviderID } from "../../provider/schema"
import { AppRuntime } from "../../effect/app-runtime"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -81,7 +82,7 @@ export const ProviderRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await ProviderAuth.methods())
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
},
)
.post(
@@ -118,11 +119,15 @@ export const ProviderRoutes = lazy(() =>
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const result = await ProviderAuth.authorize({
providerID,
method,
inputs,
})
const result = await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.authorize({
providerID,
method,
inputs,
}),
),
)
return c.json(result)
},
)
@@ -160,11 +165,15 @@ export const ProviderRoutes = lazy(() =>
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await ProviderAuth.callback({
providerID,
method,
code,
})
await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.callback({
providerID,
method,
code,
}),
),
)
return c.json(true)
},
),

View File

@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Command } from "../../command"
@@ -93,7 +94,7 @@ export const SessionRoutes = lazy(() =>
},
}),
async (c) => {
const result = await SessionStatus.list()
const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
return c.json(Object.fromEntries(result))
},
)
@@ -185,7 +186,7 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const todos = await Todo.get(sessionID)
const todos = await AppRuntime.runPromise(Todo.Service.use((svc) => svc.get(sessionID)))
return c.json(todos)
},
)

View File

@@ -15,7 +15,7 @@ import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/db"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow"
@@ -58,7 +58,7 @@ export namespace SessionCompaction {
}) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const layer: Layer.Layer<
Service,

View File

@@ -29,7 +29,7 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Option, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
export namespace Session {
@@ -352,19 +352,25 @@ export namespace Session {
field: string
delta: string
}) => Effect.Effect<void>
/** Finds the first message matching the predicate, searching newest-first. */
readonly findMessage: (
sessionID: SessionID,
predicate: (msg: MessageV2.WithParts) => boolean,
) => Effect.Effect<Option.Option<MessageV2.WithParts>>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
type Patch = z.infer<typeof Event.Updated.schema>["info"]
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -585,9 +591,9 @@ export namespace Session {
})
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
)
return yield* storage
.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
.pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => []))
})
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
@@ -635,6 +641,17 @@ export namespace Session {
yield* bus.publish(MessageV2.Event.PartDelta, input)
})
/** Finds the first message matching the predicate, searching newest-first. */
const findMessage = Effect.fn("Session.findMessage")(function* (
sessionID: SessionID,
predicate: (msg: MessageV2.WithParts) => boolean,
) {
for (const item of MessageV2.stream(sessionID)) {
if (predicate(item)) return Option.some(item)
}
return Option.none<MessageV2.WithParts>()
})
return Service.of({
create,
fork,
@@ -656,11 +673,12 @@ export namespace Session {
updatePart,
getPart,
updatePartDelta,
findMessage,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -832,15 +850,4 @@ export namespace Session {
MessageV2.Part.parse(part)
return runPromise((svc) => svc.updatePart(part))
}
export const updatePartDelta = fn(
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
partID: PartID.zod,
field: z.string(),
delta: z.string(),
}),
(input) => runPromise((svc) => svc.updatePartDelta(input)),
)
}

View File

@@ -1,6 +1,6 @@
import os from "os"
import path from "path"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
@@ -64,7 +64,7 @@ export namespace Instruction {
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Instruction") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect(

View File

@@ -1,6 +1,6 @@
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
import { Cause, Effect, Layer, Record, Context } from "effect"
import * as Queue from "effect/Queue"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
@@ -51,7 +51,7 @@ export namespace LLM {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LLM") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
export const layer = Layer.effect(
Service,

View File

@@ -15,6 +15,7 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
import { EffectLogger } from "@/effect/logger"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
@@ -839,7 +840,7 @@ export namespace MessageV2 {
model: Provider.Model,
options?: { stripMedia?: boolean },
): Promise<ModelMessage[]> {
return Effect.runPromise(toModelMessagesEffect(input, model, options))
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
}
export function page(input: { sessionID: SessionID; limit: number; before?: string }) {

View File

@@ -1,4 +1,4 @@
import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect"
import { Cause, Deferred, Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -6,7 +6,7 @@ import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import { EffectLogger } from "@/effect/logger"
import { Session } from "."
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
@@ -23,7 +23,7 @@ import { isRecord } from "@/util/record"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
const log = EffectLogger.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue"
@@ -76,7 +76,7 @@ export namespace SessionProcessor {
type StreamEvent = Event
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionProcessor") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
export const layer: Layer.Layer<
Service,
@@ -121,6 +121,7 @@ export namespace SessionProcessor {
reasoningMap: {},
}
let aborted = false
const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id })
const parse = (e: unknown) =>
MessageV2.fromError(e, {
@@ -245,7 +246,7 @@ export namespace SessionProcessor {
case "reasoning-end":
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text.trimEnd()
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePart(ctx.reasoningMap[value.id])
@@ -425,7 +426,7 @@ export namespace SessionProcessor {
case "text-end":
if (!ctx.currentText) return
ctx.currentText.text = ctx.currentText.text.trimEnd()
ctx.currentText.text = ctx.currentText.text
ctx.currentText.text = (yield* plugin.trigger(
"experimental.text.complete",
{
@@ -448,7 +449,7 @@ export namespace SessionProcessor {
return
default:
log.info("unhandled", { ...value })
yield* slog.info("unhandled", { event: value.type, value })
return
}
})
@@ -514,7 +515,7 @@ export namespace SessionProcessor {
})
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
log.error("process", { error: e, stack: e instanceof Error ? e.stack : undefined })
yield* slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
@@ -530,7 +531,7 @@ export namespace SessionProcessor {
})
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
log.info("process")
yield* slog.info("process")
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true

View File

@@ -43,10 +43,11 @@ import { AppFileSystem } from "@/filesystem"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
// @ts-ignore
@@ -64,6 +65,7 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
@@ -74,7 +76,7 @@ export namespace SessionPrompt {
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionPrompt") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}
export const layer = Layer.effect(
Service,
@@ -101,8 +103,14 @@ export namespace SessionPrompt {
const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service
const run = {
promise: <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
}
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
log.info("cancel", { sessionID })
yield* elog.info("cancel", { sessionID })
yield* state.cancel(sessionID)
})
@@ -196,11 +204,7 @@ export namespace SessionPrompt {
const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
yield* sessions
.setTitle({ sessionID: input.session.id, title: t })
.pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.error("failed to generate title", { error: Cause.squash(cause) })),
),
)
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
})
const insertReminders = Effect.fn("SessionPrompt.insertReminders")(function* (input: {
@@ -356,34 +360,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
Effect.runPromise(
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
),
input.processor.updateToolCall(options.toolCallId, (match) => {
if (!["running", "pending"].includes(match.state.status)) return match
return {
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: { start: Date.now() },
},
}
}),
ask: (req) =>
Effect.runPromise(
permission.ask({
permission
.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
}),
),
})
.pipe(Effect.orDie),
})
for (const item of yield* registry.tools({
@@ -397,7 +399,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
description: item.description,
inputSchema: jsonSchema(schema as any),
execute(args, options) {
return Effect.runPromise(
return run.promise(
Effect.gen(function* () {
const ctx = context(args, options)
yield* plugin.trigger(
@@ -405,7 +407,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
{ args },
)
const result = yield* Effect.promise(() => item.execute(args, ctx))
const result = yield* item.execute(args, ctx)
const output = {
...result,
attachments: result.attachments?.map((attachment) => ({
@@ -438,7 +440,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const transformed = ProviderTransform.schema(input.model, schema)
item.inputSchema = jsonSchema(transformed)
item.execute = (args, opts) =>
Effect.runPromise(
run.promise(
Effect.gen(function* () {
const ctx = context(args, opts)
yield* plugin.trigger(
@@ -446,7 +448,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }))
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
execute(args, opts),
)
@@ -578,63 +580,61 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
let error: Error | undefined
const result = yield* Effect.promise((signal) =>
taskTool
.execute(taskArgs, {
agent: task.agent,
messageID: assistantMessage.id,
sessionID,
abort: signal,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
metadata(val: { title?: string; metadata?: Record<string, any> }) {
return Effect.runPromise(
Effect.gen(function* () {
part = yield* sessions.updatePart({
...part,
type: "tool",
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)
}),
)
},
ask(req: any) {
return Effect.runPromise(
permission.ask({
...req,
sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
}),
)
},
})
.catch((e) => {
error = e instanceof Error ? e : new Error(String(e))
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
}),
).pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
yield* sessions.updateMessage(assistantMessage)
if (part.state.status === "running") {
yield* sessions.updatePart({
const taskAbort = new AbortController()
const result = yield* taskTool
.execute(taskArgs, {
agent: task.agent,
messageID: assistantMessage.id,
sessionID,
abort: taskAbort.signal,
callID: part.callID,
extra: { bypassAgentCheck: true, promptOps },
messages: msgs,
metadata: (val: { title?: string; metadata?: Record<string, any> }) =>
Effect.gen(function* () {
part = yield* sessions.updatePart({
...part,
state: {
status: "error",
error: "Cancelled",
time: { start: part.state.time.start, end: Date.now() },
metadata: part.state.metadata,
input: part.state.input,
},
type: "tool",
state: { ...part.state, ...val },
} satisfies MessageV2.ToolPart)
}
}),
ask: (req: any) =>
permission
.ask({
...req,
sessionID,
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
})
.pipe(Effect.orDie),
})
.pipe(
Effect.catchCause((cause) => {
const defect = Cause.squash(cause)
error = defect instanceof Error ? defect : new Error(String(defect))
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return Effect.void
}),
),
)
Effect.onInterrupt(() =>
Effect.gen(function* () {
taskAbort.abort()
assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now()
yield* sessions.updateMessage(assistantMessage)
if (part.state.status === "running") {
yield* sessions.updatePart({
...part,
state: {
status: "error",
error: "Cancelled",
time: { start: part.state.time.start, end: Date.now() },
metadata: part.state.metadata,
input: part.state.input,
},
} satisfies MessageV2.ToolPart)
}
}),
),
)
const attachments = result?.attachments?.map((attachment) => ({
...attachment,
@@ -857,7 +857,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
output += chunk
if (part.state.status === "running") {
part.state.metadata = { output, description: "" }
void Effect.runFork(sessions.updatePart(part))
void run.fork(sessions.updatePart(part))
}
}),
)
@@ -902,12 +902,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
const model = yield* Effect.promise(async () => {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
}
})
if (model) return model
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
return yield* provider.defaultModel()
})
@@ -1039,19 +1035,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
Effect.promise((signal: AbortSignal) =>
read.execute(args, {
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
const controller = new AbortController()
return read
.execute(args, {
sessionID: input.sessionID,
abort: signal,
abort: controller.signal,
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, ...extra },
messages: [],
metadata: async () => {},
ask: async () => {},
}),
)
metadata: () => Effect.void,
ask: () => Effect.void,
})
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
}
if (part.mime === "text/plain") {
let offset: number | undefined
@@ -1288,27 +1286,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
)
const lastAssistant = (sessionID: SessionID) =>
Effect.promise(async () => {
let latest: MessageV2.WithParts | undefined
for await (const item of MessageV2.stream(sessionID)) {
latest ??= item
if (item.info.role !== "user") return item
}
if (latest) return latest
throw new Error("Impossible")
})
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
if (Option.isSome(match)) return match.value
const msgs = yield* sessions.messages({ sessionID, limit: 1 })
if (msgs.length > 0) return msgs[0]
throw new Error("Impossible")
})
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown | undefined
let step = 0
const session = yield* sessions.get(sessionID)
while (true) {
yield* status.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID })
yield* slog.info("loop", { step })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
@@ -1344,7 +1340,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
log.info("exiting loop", { sessionID })
yield* slog.info("exiting loop")
break
}
@@ -1540,7 +1536,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
)
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
log.info("command", input)
yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
const cmd = yield* commands.get(input.command)
if (!cmd) {
const available = (yield* commands.list()).map((c) => c.name)
@@ -1655,6 +1651,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return result
})
const promptOps: TaskPromptOps = {
cancel: (sessionID) => run.fork(cancel(sessionID)),
resolvePromptParts: (template) => resolvePromptParts(template),
prompt: (input) => prompt(input),
}
return Service.of({
cancel,
prompt,
@@ -1666,7 +1668,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
)
const defaultLayer = Layer.suspend(() =>
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),

View File

@@ -1,5 +1,5 @@
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Bus } from "../bus"
import { Snapshot } from "../snapshot"
@@ -29,7 +29,7 @@ export namespace SessionRevert {
readonly cleanup: (session: Session.Info) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRevert") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,7 +1,7 @@
import { InstanceState } from "@/effect/instance-state"
import { Runner } from "@/effect/runner"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { Effect, Layer, Scope, Context } from "effect"
import { Session } from "."
import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
@@ -23,7 +23,7 @@ export namespace SessionRunState {
) => Effect.Effect<MessageV2.WithParts>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRunState") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,

View File

@@ -7,8 +7,7 @@ import { withStatics } from "@/util/schema"
export const SessionID = Schema.String.pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)),
descending: (id?: string) => s.make(Identifier.descending("session", id)),
zod: Identifier.schema("session").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
})),
)
@@ -18,8 +17,7 @@ export type SessionID = Schema.Schema.Type<typeof SessionID>
export const MessageID = Schema.String.pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)),
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
zod: Identifier.schema("message").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
})),
)
@@ -29,8 +27,7 @@ export type MessageID = Schema.Schema.Type<typeof MessageID>
export const PartID = Schema.String.pipe(
Schema.brand("PartID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)),
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
zod: Identifier.schema("part").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
})),
)

View File

@@ -1,9 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { SessionID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import z from "zod"
export namespace SessionStatus {
@@ -50,7 +49,7 @@ export namespace SessionStatus {
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionStatus") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
export const layer = Layer.effect(
Service,
@@ -86,17 +85,4 @@ export namespace SessionStatus {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID))
}
export async function list() {
return runPromise((svc) => svc.list())
}
export async function set(sessionID: SessionID, status: Info) {
return runPromise((svc) => svc.set(sessionID, status))
}
}

View File

@@ -1,5 +1,5 @@
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot"
@@ -71,7 +71,7 @@ export namespace SessionSummary {
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionSummary") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,8 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { makeRuntime } from "@/effect/run-service"
import { SessionID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import z from "zod"
import { Database, eq, asc } from "../storage/db"
import { TodoTable } from "./session.sql"
@@ -32,7 +31,7 @@ export namespace Todo {
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionTodo") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
export const layer = Layer.effect(
Service,
@@ -83,9 +82,4 @@ export namespace Todo {
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(sessionID: SessionID) {
return runPromise((svc) => svc.get(sessionID))
}
}

View File

@@ -3,7 +3,7 @@ import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { SyncEvent } from "@/sync"
import { fn } from "@/util/fn"
import { Effect, Layer, Scope, ServiceMap } from "effect"
import { Effect, Layer, Scope, Context } from "effect"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { ShareNext } from "./share-next"
@@ -15,7 +15,7 @@ export namespace SessionShare {
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionShare") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionShare") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,10 +1,9 @@
import type * as SDK from "@opencode-ai/sdk/v2"
import { Effect, Exit, Layer, Option, Schema, Scope, ServiceMap, Stream } from "effect"
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account } from "@/account"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session"
@@ -74,7 +73,7 @@ export namespace ShareNext {
readonly remove: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ShareNext") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ShareNext") {}
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
@@ -348,26 +347,4 @@ export namespace ShareNext {
Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((svc) => svc.init())
}
export async function url() {
return runPromise((svc) => svc.url())
}
export async function request(): Promise<Req> {
return runPromise((svc) => svc.request())
}
export async function create(sessionID: SessionID) {
return runPromise((svc) => svc.create(sessionID))
}
export async function remove(sessionID: SessionID) {
return runPromise((svc) => svc.remove(sessionID))
}
}

View File

@@ -1,5 +1,5 @@
import { NodePath } from "@effect/platform-node"
import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
import { Effect, Layer, Path, Schema, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AppFileSystem } from "@/filesystem"
@@ -23,7 +23,7 @@ export namespace Discovery {
readonly pull: (url: string) => Effect.Effect<string[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
Layer.effect(

View File

@@ -2,7 +2,7 @@ import os from "os"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -187,7 +187,7 @@ export namespace Skill {
log.info("init", { count: Object.keys(state.skills).length })
})
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
export const layer = Layer.effect(
Service,

View File

@@ -1,4 +1,4 @@
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
@@ -57,7 +57,7 @@ export namespace Snapshot {
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<
Service,

View File

@@ -2,7 +2,7 @@ import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
import { Context } from "../util/context"
import { LocalContext } from "../util/local-context"
import { lazy } from "../util/lazy"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -122,7 +122,7 @@ export namespace Database {
export type TxOrDb = Transaction | Client
const ctx = Context.create<{
const ctx = LocalContext.create<{
tx: TxOrDb
effects: (() => void | Promise<void>)[]
}>("database")
@@ -131,7 +131,7 @@ export namespace Database {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof Context.NotFound) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
for (const effect of effects) effect()
@@ -161,7 +161,7 @@ export namespace Database {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof Context.NotFound) {
if (err instanceof LocalContext.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
const result = Client().transaction(txCallback, { behavior: options?.behavior })

View File

@@ -4,8 +4,7 @@ import { Global } from "../global"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
import { Git } from "@/git"
export namespace Storage {
@@ -66,7 +65,7 @@ export namespace Storage {
readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Storage") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
function file(dir: string, key: string[]) {
return path.join(dir, ...key) + ".json"
@@ -331,26 +330,4 @@ export namespace Storage {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function remove(key: string[]) {
return runPromise((svc) => svc.remove(key))
}
export async function read<T>(key: string[]) {
return runPromise((svc) => svc.read<T>(key))
}
export async function update<T>(key: string[], fn: (draft: T) => void) {
return runPromise((svc) => svc.update<T>(key, fn))
}
export async function write<T>(key: string[], content: T) {
return runPromise((svc) => svc.write(key, content))
}
export async function list(prefix: string[]) {
return runPromise((svc) => svc.list(prefix))
}
}

View File

@@ -7,8 +7,7 @@ import { withStatics } from "@/util/schema"
export const EventID = Schema.String.pipe(
Schema.brand("EventID"),
withStatics((s) => ({
make: (id: string) => s.makeUnsafe(id),
ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("event", id)),
ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
zod: Identifier.schema("event").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
})),
)

View File

@@ -1,16 +1,16 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Effect } from "effect"
import { Tool } from "./tool"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { Format } from "../format"
@@ -19,261 +19,268 @@ const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.define("apply_patch", {
description: DESCRIPTION,
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
export const ApplyPatchTool = Tool.define(
"apply_patch",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
throw new Error("patch rejected: empty patch")
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
if (!params.patchText) {
return yield* Effect.fail(new Error("patchText is required"))
}
throw new Error("apply_patch verification failed: no hunks found")
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
return yield* Effect.fail(new Error("patch rejected: empty patch"))
}
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
}
case "update": {
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
yield* assertExternalDirectoryEffect(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
case "update": {
// Check if file exists for update
const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stats || stats.type === "Directory") {
return yield* Effect.fail(
new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
)
}
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
const oldContent = yield* afs.readFileString(filePath)
let newContent = oldContent
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
yield* assertExternalDirectoryEffect(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
case "delete": {
const contentToDelete = yield* afs
.readFileString(filePath)
.pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))))
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
case "delete": {
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`apply_patch verification failed: ${error}`)
})
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
}
}
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
await ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
yield* ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
await fs.writeFile(change.filePath, change.newContent, "utf-8")
updates.push({ file: change.filePath, event: "add" })
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
updates.push({ file: change.filePath, event: "change" })
break
case "move":
if (change.movePath) {
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
await fs.writeFile(change.movePath, change.newContent, "utf-8")
await fs.unlink(change.filePath)
yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "add" })
break
case "update":
yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "change" })
break
case "move":
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
yield* afs.writeWithDirs(change.movePath!, change.newContent)
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
case "delete":
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
break
}
case "delete":
await fs.unlink(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
break
if (edited) {
yield* format.file(edited)
yield* bus.publish(File.Event.Edited, { file: edited })
}
}
if (edited) {
await Format.file(edited)
Bus.publish(File.Event.Edited, { file: edited })
// Publish file change events
for (const update of updates) {
yield* bus.publish(FileWatcher.Event.Updated, update)
}
}
// Publish file change events
for (const update of updates) {
await Bus.publish(FileWatcher.Event.Updated, update)
}
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
await LSP.touchFile(target, true)
}
const diagnostics = await LSP.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
yield* lsp.touchFile(target, true)
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
const diagnostics = yield* lsp.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
if (!block) continue
const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/")
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
}
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
// Report LSP errors for changed files
const MAX_DIAGNOSTICS_PER_FILE = 20
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const normalized = Filesystem.normalizePath(target)
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
description: DESCRIPTION,
parameters: PatchParams,
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
}
},
})
}),
)

View File

@@ -226,25 +226,21 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
return path.join(dir, "*")
})
yield* Effect.promise(() =>
ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
}),
)
yield* ctx.ask({
permission: "external_directory",
patterns: globs,
always: globs,
metadata: {},
})
}
if (scan.patterns.size === 0) return
yield* Effect.promise(() =>
ctx.ask({
permission: "bash",
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
}),
)
yield* ctx.ask({
permission: "bash",
patterns: Array.from(scan.patterns),
always: Array.from(scan.always),
metadata: {},
})
})
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
@@ -294,7 +290,7 @@ const parser = lazy(async () => {
})
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.defineEffect(
export const BashTool = Tool.define(
"bash",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
@@ -389,7 +385,7 @@ export const BashTool = Tool.defineEffect(
let expired = false
let aborted = false
ctx.metadata({
yield* ctx.metadata({
metadata: {
output: "",
description: input.description,
@@ -401,17 +397,15 @@ export const BashTool = Tool.defineEffect(
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
Effect.sync(() => {
output += chunk
ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
),
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
output += chunk
return ctx.metadata({
metadata: {
output: preview(output),
description: input.description,
},
})
}),
)
const abort = Effect.callback<void>((resume) => {
@@ -504,7 +498,7 @@ export const BashTool = Tool.defineEffect(
},
ctx,
)
}).pipe(Effect.orDie, Effect.runPromise),
}),
}
}
}),

View File

@@ -5,7 +5,7 @@ import { Tool } from "./tool"
import * as McpExa from "./mcp-exa"
import DESCRIPTION from "./codesearch.txt"
export const CodeSearchTool = Tool.defineEffect(
export const CodeSearchTool = Tool.define(
"codesearch",
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
@@ -29,17 +29,15 @@ export const CodeSearchTool = Tool.defineEffect(
}),
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
}),
)
yield* ctx.ask({
permission: "codesearch",
patterns: [params.query],
always: ["*"],
metadata: {
query: params.query,
tokensNum: params.tokensNum,
},
})
const result = yield* McpExa.call(
http,
@@ -59,7 +57,7 @@ export const CodeSearchTool = Tool.defineEffect(
title: `Code search: ${params.query}`,
metadata: {},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -5,6 +5,7 @@
import z from "zod"
import * as path from "path"
import { Effect } from "effect"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -17,9 +18,8 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
@@ -34,136 +34,158 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
const Parameters = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
await FileTime.read(ctx.sessionID, filePath)
return
}
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Format.file(filePath)
Bus.publish(File.Event.Edited, { file: filePath })
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
export const EditTool = Tool.define(
"edit",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const filetime = yield* FileTime.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
return {
metadata: {
diagnostics,
diff,
filediff,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const filePath = path.isAbsolute(params.filePath)
? params.filePath
: path.join(Instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath)
let diff = ""
let contentOld = ""
let contentNew = ""
yield* filetime.withLock(filePath, () =>
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, params.newString)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
yield* filetime.read(ctx.sessionID, filePath)
return
}
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
yield* filetime.assert(ctx.sessionID, filePath)
contentOld = yield* afs.readFileString(filePath)
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
yield* afs.writeWithDirs(filePath, contentNew)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = yield* afs.readFileString(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
)
yield* filetime.read(ctx.sessionID, filePath)
}).pipe(Effect.orDie),
)
const filediff: Snapshot.FileDiff = {
file: filePath,
patch: diff,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
yield* ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
yield* lsp.touchFile(filePath, true)
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
return {
metadata: {
diagnostics,
diff,
filediff,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
}
}),
}
},
})
}),
)
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>

View File

@@ -1,5 +1,6 @@
import path from "path"
import { Effect } from "effect"
import { EffectLogger } from "@/effect/logger"
import type { Tool } from "./tool"
import { Instance } from "../project/instance"
import { AppFileSystem } from "../filesystem"
@@ -11,7 +12,11 @@ type Options = {
kind?: Kind
}
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
if (!target) return
if (options?.bypass) return
@@ -26,7 +31,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
: path.join(dir, "*").replaceAll("\\", "/")
await ctx.ask({
yield* ctx.ask({
permission: "external_directory",
patterns: [glob],
always: [glob],
@@ -35,12 +40,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
parentDir: dir,
},
})
}
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
ctx: Tool.Context,
target?: string,
options?: Options,
) {
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
})
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options).pipe(Effect.provide(EffectLogger.layer)))
}

View File

@@ -9,7 +9,7 @@ import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.defineEffect(
export const GlobTool = Tool.define(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
@@ -28,17 +28,15 @@ export const GlobTool = Tool.defineEffect(
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
}),
)
yield* ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
@@ -90,7 +88,7 @@ export const GlobTool = Tool.defineEffect(
},
output: output.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -1,156 +1,175 @@
import z from "zod"
import { text } from "node:stream/consumers"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { Process } from "../util/process"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define("grep", {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params, ctx) {
if (!params.pattern) {
throw new Error("pattern is required")
}
await ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
})
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const proc = Process.spawn([rgPath, ...args], {
stdout: "pipe",
stderr: "pipe",
abort: ctx.abort,
})
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const output = await text(proc.stdout)
const errorOutput = await text(proc.stderr)
const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
export const GrepTool = Tool.define(
"grep",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return {
title: params.pattern,
metadata: {
matches: totalMatches,
truncated,
},
output: outputLines.join("\n"),
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.pattern) {
throw new Error("pattern is required")
}
yield* ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
})
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
const [output, errorOutput] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const exitCode = yield* handle.exitCode
return { output, errorOutput, exitCode }
}),
)
const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
title: params.pattern,
metadata: {
matches: totalMatches,
truncated,
},
output: outputLines.join("\n"),
}
}).pipe(Effect.orDie),
}
},
})
}),
)

View File

@@ -1,17 +1,20 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
export const InvalidTool = Tool.define("invalid", {
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
export const InvalidTool = Tool.define(
"invalid",
Effect.succeed({
description: "Do not use",
parameters: z.object({
tool: z.string(),
error: z.string(),
}),
execute: (params: { tool: string; error: string }) =>
Effect.succeed({
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}),
}),
async execute(params) {
return {
title: "Invalid Tool",
output: `The arguments provided to the tool are invalid: ${params.error}`,
metadata: {},
}
},
})
)

View File

@@ -1,10 +1,12 @@
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import * as path from "path"
import DESCRIPTION from "./ls.txt"
import { Instance } from "../project/instance"
import { Ripgrep } from "../file/ripgrep"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
export const IGNORE_PATTERNS = [
"node_modules/",
@@ -35,87 +37,98 @@ export const IGNORE_PATTERNS = [
const LIMIT = 100
export const ListTool = Tool.define("list", {
description: DESCRIPTION,
parameters: z.object({
path: z.string().describe("The absolute path to the directory to list (must be absolute, not relative)").optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
async execute(params, ctx) {
const searchPath = path.resolve(Instance.directory, params.path || ".")
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
await ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = []
for await (const file of Ripgrep.files({ cwd: searchPath, glob: ignoreGlobs, signal: ctx.abort })) {
files.push(file)
if (files.length >= LIMIT) break
}
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
export const ListTool = Tool.define(
"list",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return {
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
description: DESCRIPTION,
parameters: z.object({
path: z
.string()
.describe("The absolute path to the directory to list (must be absolute, not relative)")
.optional(),
ignore: z.array(z.string()).describe("List of glob patterns to ignore").optional(),
}),
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
Effect.gen(function* () {
const searchPath = path.resolve(Instance.directory, params.path || ".")
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
yield* ctx.ask({
permission: "list",
patterns: [searchPath],
always: ["*"],
metadata: {
path: searchPath,
},
})
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
Stream.take(LIMIT),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
// Build directory structure
const dirs = new Set<string>()
const filesByDir = new Map<string, string[]>()
for (const file of files) {
const dir = path.dirname(file)
const parts = dir === "." ? [] : dir.split("/")
// Add all parent directories
for (let i = 0; i <= parts.length; i++) {
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
dirs.add(dirPath)
}
// Add file to its directory
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
filesByDir.get(dir)!.push(path.basename(file))
}
function renderDir(dirPath: string, depth: number): string {
const indent = " ".repeat(depth)
let output = ""
if (depth > 0) {
output += `${indent}${path.basename(dirPath)}/\n`
}
const childIndent = " ".repeat(depth + 1)
const children = Array.from(dirs)
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
.sort()
// Render subdirectories first
for (const child of children) {
output += renderDir(child, depth + 1)
}
// Render files
const files = filesByDir.get(dirPath) || []
for (const file of files.sort()) {
output += `${childIndent}${file}\n`
}
return output
}
const output = `${searchPath}/\n` + renderDir(".", 0)
return {
title: path.relative(Instance.worktree, searchPath),
metadata: {
count: files.length,
truncated: files.length >= LIMIT,
},
output,
}
}).pipe(Effect.orDie),
}
},
})
}),
)

View File

@@ -21,7 +21,7 @@ const operations = [
"outgoingCalls",
] as const
export const LspTool = Tool.defineEffect(
export const LspTool = Tool.define(
"lsp",
Effect.gen(function* () {
const lsp = yield* LSP.Service
@@ -42,7 +42,7 @@ export const LspTool = Tool.defineEffect(
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
@@ -85,7 +85,7 @@ export const LspTool = Tool.defineEffect(
metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
}
}).pipe(Effect.runPromise),
}),
}
}),
)

View File

@@ -1,46 +1,61 @@
import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool"
import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt"
import path from "path"
import { Instance } from "../project/instance"
export const MultiEditTool = Tool.define("multiedit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
async execute(params, ctx) {
const tool = await EditTool.init()
const results = []
for (const [, edit] of params.edits.entries()) {
const result = await tool.execute(
{
filePath: params.filePath,
oldString: edit.oldString,
newString: edit.newString,
replaceAll: edit.replaceAll,
},
ctx,
)
results.push(result)
}
export const MultiEditTool = Tool.define(
"multiedit",
Effect.gen(function* () {
const editInfo = yield* EditTool
const edit = yield* Effect.promise(() => editInfo.init())
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
)
.describe("Array of edit operations to perform sequentially on the file"),
}),
execute: (
params: {
filePath: string
edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }>
},
ctx: Tool.Context,
) =>
Effect.gen(function* () {
const results = []
for (const [, entry] of params.edits.entries()) {
const result = yield* edit.execute(
{
filePath: params.filePath,
oldString: entry.oldString,
newString: entry.newString,
replaceAll: entry.replaceAll,
},
ctx,
)
results.push(result)
}
return {
title: path.relative(Instance.worktree, params.filePath),
metadata: {
results: results.map((r) => r.metadata),
},
output: results.at(-1)!.output,
}
}),
}
},
})
}),
)

View File

@@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) {
return undefined
}
export const PlanExitTool = Tool.defineEffect(
export const PlanExitTool = Tool.define(
"plan_exit",
Effect.gen(function* () {
const session = yield* Session.Service
@@ -74,7 +74,7 @@ export const PlanExitTool = Tool.defineEffect(
output: "User approved switching to build agent. Wait for further instructions.",
metadata: {},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -12,7 +12,7 @@ type Metadata = {
answers: Question.Answer[]
}
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
"question",
Effect.gen(function* () {
const question = yield* Question.Service
@@ -39,7 +39,7 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
answers,
},
}
}).pipe(Effect.runPromise),
}).pipe(Effect.orDie),
}
}),
)

View File

@@ -25,7 +25,7 @@ const parameters = z.object({
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
})
export const ReadTool = Tool.defineEffect(
export const ReadTool = Tool.define(
"read",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
@@ -106,14 +106,12 @@ export const ReadTool = Tool.defineEffect(
kind: stat?.type === "Directory" ? "directory" : "file",
})
yield* Effect.promise(() =>
ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
}),
)
yield* ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"],
metadata: {},
})
if (!stat) return yield* miss(filepath)
@@ -218,9 +216,7 @@ export const ReadTool = Tool.defineEffect(
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
}
}),
)

View File

@@ -29,11 +29,12 @@ import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
@@ -43,6 +44,7 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import { Bus } from "../bus"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
@@ -71,7 +73,7 @@ export namespace ToolRegistry {
}) => Effect.Effect<Tool.Def[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const layer: Layer.Layer<
Service,
@@ -88,9 +90,12 @@ export namespace ToolRegistry {
| FileTime.Service
| Instruction.Service
| AppFileSystem.Service
| Bus.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
| Format.Service
| Truncate.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -98,7 +103,9 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service
const agents = yield* Agent.Service
const skill = yield* Skill.Service
const truncate = yield* Truncate.Service
const invalid = yield* InvalidTool
const task = yield* TaskTool
const read = yield* ReadTool
const question = yield* QuestionTool
@@ -110,6 +117,11 @@ export namespace ToolRegistry {
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool
const writetool = yield* WriteTool
const edit = yield* EditTool
const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -120,23 +132,26 @@ export namespace ToolRegistry {
id,
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx: PluginToolContext = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
},
execute: (args, toolCtx) =>
Effect.gen(function* () {
const pluginCtx: PluginToolContext = {
...toolCtx,
ask: (req) => toolCtx.ask(req),
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
const out = yield* truncate.output(result, {}, agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
}),
}
}
@@ -167,20 +182,20 @@ export namespace ToolRegistry {
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const tool = yield* Effect.all({
invalid: Tool.init(InvalidTool),
invalid: Tool.init(invalid),
bash: Tool.init(bash),
read: Tool.init(read),
glob: Tool.init(globtool),
grep: Tool.init(GrepTool),
edit: Tool.init(EditTool),
write: Tool.init(WriteTool),
grep: Tool.init(greptool),
edit: Tool.init(edit),
write: Tool.init(writetool),
task: Tool.init(task),
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(websearch),
code: Tool.init(codesearch),
skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool),
skill: Tool.init(skilltool),
patch: Tool.init(patchtool),
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
@@ -321,9 +336,12 @@ export namespace ToolRegistry {
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Truncate.defaultLayer),
),
)

View File

@@ -10,8 +10,7 @@ export type ToolID = typeof toolIdSchema.Type
export const ToolID = toolIdSchema.pipe(
withStatics((schema: typeof toolIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("tool", id)),
ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)),
zod: Identifier.schema("tool").pipe(z.custom<ToolID>()),
})),
)

View File

@@ -1,99 +1,100 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import { EffectLogger } from "@/effect/logger"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.available()
export const SkillTool = Tool.define(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return async () => {
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
return {
description,
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
await ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(skill.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
title: `Loaded skill: ${skill.name}`,
output: [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: skill.name,
dir,
},
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
},
}
})
}
}),
)

View File

@@ -5,11 +5,17 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config"
import { Effect } from "effect"
import { Log } from "@/util/log"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
}
const id = "task"
const parameters = z.object({
@@ -25,27 +31,26 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.defineEffect(
export const TaskTool = Tool.define(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
if (!ctx.extra?.bypassAgentCheck) {
yield* Effect.promise(() =>
ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
yield* ctx.ask({
permission: id,
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
}
const next = yield* agent.get(params.subagent_type)
@@ -58,44 +63,39 @@ export const TaskTool = Tool.defineEffect(
const taskID = params.task_id
const session = taskID
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const nextSession =
session ??
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}))
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
@@ -105,7 +105,7 @@ export const TaskTool = Tool.defineEffect(
providerID: msg.info.providerID,
}
ctx.metadata({
yield* ctx.metadata({
title: params.description,
metadata: {
sessionId: nextSession.id,
@@ -113,10 +113,13 @@ export const TaskTool = Tool.defineEffect(
},
})
const ops = ctx.extra?.promptOps as TaskPromptOps
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(nextSession.id)
ops.cancel(nextSession.id)
}
return yield* Effect.acquireUseRelease(
@@ -125,24 +128,22 @@ export const TaskTool = Tool.defineEffect(
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
SessionPrompt.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
const parts = yield* ops.resolvePromptParts(params.prompt)
const result = yield* ops.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
})
return {
title: params.description,
@@ -169,9 +170,7 @@ export const TaskTool = Tool.defineEffect(
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
}
}),
)

View File

@@ -12,7 +12,7 @@ type Metadata = {
todos: Todo.Info[]
}
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
"todowrite",
Effect.gen(function* () {
const todo = yield* Todo.Service
@@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
return {
description: DESCRIPTION_WRITE,
parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
await ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
yield* ctx.ask({
permission: "todowrite",
patterns: ["*"],
always: ["*"],
metadata: {},
})
await todo
.update({
yield* todo.update({
sessionID: ctx.sessionID,
todos: params.todos,
})
.pipe(Effect.runPromise)
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
},
return {
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
output: JSON.stringify(params.todos, null, 2),
metadata: {
todos: params.todos,
},
}
}),
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)

Some files were not shown because too many files have changed in this diff Show More