Compare commits

..

72 Commits

Author SHA1 Message Date
Kit Langton
c3eb2418ca fix(sync): preserve shared state and caller semantics 2026-04-11 00:10:41 -04:00
Kit Langton
397adb4190 fix(sync): restore instance context for deferred publish 2026-04-11 00:10:41 -04:00
Kit Langton
dbc136d3b3 Merge branch 'dev' into kit/effect-sync-event 2026-04-10 23:54:56 -04: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
e2c41dce99 Merge branch 'dev' into kit/effect-sync-event 2026-04-10 23:51:34 -04:00
Kit Langton
d84cc33742 refactor(plugin): return Effect from ToolContext.ask (#21986) 2026-04-10 23:50:50 -04:00
Kit Langton
f8580ab9f7 Merge branch 'dev' into kit/effect-sync-event 2026-04-10 23:42:10 -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
Kit Langton
af7ada9a7a refactor(sync): migrate SyncEvent to service layer 2026-04-10 23:34:38 -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
Aiden Cline
ce26120205 tweak: make it so disabling uv or ruff fmters disables both (#21921) 2026-04-10 13:30:30 -05:00
opencode-agent[bot]
d2d5d84d1e chore: generate 2026-04-10 17:57:46 +00:00
Kit Langton
847f1d99c9 convert glob tool to Tool.defineEffect (#21897) 2026-04-10 13:56:42 -04:00
opencode-agent[bot]
59d08683ea chore: generate 2026-04-10 17:27:31 +00:00
Kit Langton
f7514d9eca refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895) 2026-04-10 13:26:31 -04:00
James Long
180ded6a27 rector(core,tui): handle workspace state in project context, add workspace status, improve ui (#21896) 2026-04-10 13:03:20 -04:00
Kit Langton
bf601628db refactor(tool): convert codesearch tool internals to Effect (#21811) 2026-04-10 11:49:20 -04:00
opencode-agent[bot]
00e39d2114 chore: generate 2026-04-10 15:31:39 +00:00
Kit Langton
46b74e0873 refactor(tool): convert websearch tool internals to Effect (#21810) 2026-04-10 11:30:38 -04:00
opencode-agent[bot]
aedc4e964f chore: generate 2026-04-10 14:51:27 +00:00
Kit Langton
e83404367c refactor(tool): convert webfetch tool internals to Effect (#21809) 2026-04-10 10:50:13 -04:00
James Long
42206da1f8 refactor(tui): switch to global events and start passing workspace param (#21719) 2026-04-10 10:47:27 -04:00
Kit Langton
44f38193c0 refactor(tool): convert plan tool internals to Effect (#21807) 2026-04-10 10:38:46 -04:00
opencode-agent[bot]
9a6b455bfe chore: generate 2026-04-10 14:08:27 +00:00
Kit Langton
8063e0b5c6 refactor(tool): convert lsp tool internals to Effect (#21806) 2026-04-10 10:07:19 -04:00
Kit Langton
157c5d77f8 refactor(tool): convert question tool internals to Effect (#21808) 2026-04-10 09:42:06 -04:00
Aiden Cline
ce19c051be fix: ts lsp (#21827) 2026-04-10 00:15:45 -05:00
Kit Langton
91786d2fc1 refactor(effect): use Git service in file and storage (#21803) 2026-04-09 22:49:36 -04:00
192 changed files with 5994 additions and 5133 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-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=",
"aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=",
"aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=",
"x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4="
}
}

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

@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.kv.get`, `set`, `ready`
- `api.state`
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
- `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
- `api.client`
- `api.event.on(type, handler)`
- `api.renderer`
- `api.slots.register(plugin)`
@@ -270,7 +270,6 @@ Command behavior:
- `provider`
- `path.{state,config,worktree,directory}`
- `vcs?.branch`
- `workspace.list()` / `workspace.get(workspaceID)`
- `session.count()`
- `session.diff(sessionID)`
- `session.todo(sessionID)`
@@ -282,8 +281,6 @@ Command behavior:
- `lsp()`
- `mcp()`
- `api.client` always reflects the current runtime client.
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
- `api.renderer` exposes the raw `CliRenderer`.

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

@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
project?: string
workspace?: string
payload: any
},
]

View File

@@ -1,9 +1,10 @@
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 { Instance } from "../project/instance"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
@@ -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,
@@ -91,8 +92,13 @@ export namespace Bus {
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
@@ -141,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)))
}
})
}
@@ -164,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

@@ -14,7 +14,6 @@ import {
batch,
Show,
on,
onCleanup,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag"
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync"
@@ -35,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -54,7 +54,6 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
@@ -216,27 +215,29 @@ export function tui(input: {
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
<ProjectProvider>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
@@ -260,6 +261,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const event = useEvent()
const sdk = useSDK()
const toast = useToast()
const themeState = useTheme()
@@ -283,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
route,
routes,
bump: () => setRouteRev((x) => x + 1),
event,
sdk,
sync,
theme: themeState,
@@ -461,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.replace(() => <DialogSessionList />)
},
},
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{
title: "New session",
suggested: route.data.type === "session",
@@ -491,12 +478,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
workspaceID,
})
dialog.clear()
},
@@ -806,11 +790,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
])
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
})
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({
title: evt.properties.title,
message: evt.properties.message,
@@ -819,14 +803,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({
type: "session",
sessionID: evt.properties.sessionID,
})
})
sdk.event.on("session.deleted", (evt) => {
event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
toast.show({
@@ -836,7 +820,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
})
sdk.event.on("session.error", (evt) => {
event.on("session.error", (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error)
@@ -848,7 +832,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})
sdk.event.on("installation.update-available", async (evt) => {
event.on("installation.update-available", async (evt) => {
const version = evt.properties.version
const skipped = kv.get("skipped_version")

View File

@@ -2,25 +2,31 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const project = useProject()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
@@ -31,15 +37,68 @@ export function DialogSessionList() {
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
</span>
</>
)
}
} else {
footer = Locale.time(x.time.updated)
}
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
@@ -53,7 +112,7 @@ export function DialogSessionList() {
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
footer,
gutter: isWorking ? <Spinner /> : undefined,
}
})
@@ -102,6 +161,15 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]}
/>
)

View File

@@ -0,0 +1,121 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void create(option.value)
}}
/>
)
}

View File

@@ -1,320 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { setTimeout as sleep } from "node:timers/promises"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = scoped(input.sdk, input.sync, input.workspaceID)
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string) {
if (workspaceID === "__local__") {
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = scoped(sdk, sync, workspaceID)
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => {
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = scoped(sdk, sync, workspace.id)
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: "__local__",
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}

View File

@@ -250,7 +250,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item

View File

@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0
const event = useEvent()
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return
input.insertText(evt.properties.text)
setTimeout(() => {

View File

@@ -1,151 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}

View File

@@ -1,11 +1,13 @@
import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync"
import { Global } from "@/global"
export function useDirectory() {
const project = useProject()
const sync = useSync()
return createMemo(() => {
const directory = sync.data.path.directory || process.cwd()
const directory = project.instance.path().directory || process.cwd()
const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result

View File

@@ -0,0 +1,41 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project"
import { useSDK } from "./sdk"
export function useEvent() {
const project = useProject()
const sdk = useSDK()
function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
}
})
}
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
return subscribe((event) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
})
}
return {
subscribe,
on,
}
}

View File

@@ -0,0 +1,106 @@
import { batch } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const sdk = useSDK()
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
},
workspace: {
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
})
async function sync() {
const workspace = store.workspace.current
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("project", "id", project.data?.id)
})
}
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace.current
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
},
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
},
sync,
}
},
})

View File

@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
workspaceID?: string
}
export type SessionRoute = {

View File

@@ -1,10 +1,11 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let sdk = createSDK()
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
event: GlobalEvent
}>()
let queue: Event[] = []
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit(event.type, event)
emitter.emit("event", event)
}
})
}
const handleEvent = (event: Event) => {
const handleEvent = (event: GlobalEvent) => {
queue.push(event)
const elapsed = Date.now() - last
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
;(async () => {
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
const events = await sdk.global.event({ signal: ctrl.signal })
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onMount(async () => {
if (props.events) {
const unsub = await props.events.subscribe(props.directory, handleEvent)
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
} else {
startSSE()

View File

@@ -19,16 +19,16 @@ import type {
VcsInfo,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { batch, createEffect, on } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -75,8 +75,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[]
}>({
provider_next: {
all: [],
@@ -104,20 +102,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp_resource: {},
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
})
const event = useEvent()
const project = useProject()
const sdk = useSDK()
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
}
sdk.event.listen((e) => {
const event = e.details
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
bootstrap()
@@ -344,7 +335,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "lsp.updated": {
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
const workspace = project.workspace.current()
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
break
}
@@ -360,25 +352,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() {
console.log("bootstrapping")
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({}, { throwOnError: true })
.get({ workspace }, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
const configPromise = sdk.client.config.get({}, { throwOnError: true })
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
]
@@ -423,18 +418,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status().then((x) => {
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data!))
}),
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
syncWorkspaces(),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
setStore("status", "complete")
})
@@ -449,11 +445,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
}
onMount(() => {
bootstrap()
})
const fullSyncedSessions = new Set<string>()
createEffect(
on(
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
const result = {
data: store,
set: setStore,
@@ -463,6 +465,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
get path() {
return project.instance.path()
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -481,11 +486,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
sdk.client.session.todo({ sessionID, workspace }),
sdk.client.session.diff({ sessionID, workspace }),
])
setStore(
produce((draft) => {
@@ -503,12 +509,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID)
},
},
workspace: {
get(workspaceID: string) {
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap,
}
return result

View File

@@ -1,6 +1,7 @@
import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
@@ -36,6 +37,7 @@ type Input = {
route: ReturnType<typeof useRoute>
routes: RouteMap
bump: () => void
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
theme: ReturnType<typeof useTheme>
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.provider
},
get path() {
return sync.data.path
return sync.path
},
get vcs() {
if (!sync.data.vcs) return
@@ -144,14 +146,6 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
branch: sync.data.vcs.branch,
}
},
workspace: {
list() {
return sync.data.workspaceList
},
get(workspaceID) {
return sync.workspace.get(workspaceID)
},
},
session: {
count() {
return sync.data.session.length
@@ -342,7 +336,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
get client() {
return input.sdk.client
},
event: input.sdk.event,
event: input.event,
renderer: input.renderer,
slots: {
register() {

View File

@@ -1,6 +1,7 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js"
import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
@@ -18,6 +19,7 @@ const placeholder = {
export function Home() {
const sync = useSync()
const project = useProject()
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -63,11 +65,16 @@ export function Home() {
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
<TuiPluginRuntime.Slot
name="home_prompt"
mode="replace"
workspace_id={project.workspace.current()}
ref={bind}
>
<Prompt
ref={bind}
workspaceID={route.workspaceID}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
workspaceID={project.workspace.current()}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
placeholders={placeholder}
/>
</TuiPluginRuntime.Slot>

View File

@@ -15,7 +15,9 @@ import {
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -116,6 +118,8 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
@@ -172,10 +176,16 @@ export function Session() {
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
createEffect(async () => {
await sync.session
.sync(route.sessionID)
await sdk.client.session
.get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
@@ -189,13 +199,10 @@ export function Session() {
})
})
const toast = useToast()
const sdk = useSDK()
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined
sdk.event.on("message.part.updated", (evt) => {
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
@@ -224,7 +231,7 @@ export function Session() {
const dialog = useDialog()
const renderer = useRenderer()
sdk.event.on("session.status", (evt) => {
event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
const base = sync.data.path.directory
const base = sync.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)

View File

@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
subscribe: async (directory, handler) => {
const id = await client.call("subscribe", { directory })
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
if (e.id === id) {
handler(e.event)
}
subscribe: async (handler) => {
return client.on<GlobalEvent>("global.event", (e) => {
handler(e)
})
return () => {
unsub()
client.call("unsubscribe", { id })
}
},
}
}

View File

@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
keybind?: {
keybind?: Keybind.Info
title: string
side?: "left" | "right"
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
@@ -42,6 +43,7 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean
bg?: RGBA
gutter?: JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void
}
@@ -234,6 +236,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
return (
<box gap={1} paddingBottom={1}>
@@ -312,6 +316,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
id={JSON.stringify(option.value)}
flexDirection="row"
position="relative"
onMouseMove={() => {
setStore("input", "mouse")
}}
@@ -335,6 +340,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingRight={3}
gap={1}
>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -353,17 +363,38 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
<For each={keybinds()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
<box
paddingRight={2}
paddingLeft={4}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
paddingTop={1}
>
<box flexDirection="row" gap={2}>
<For each={left()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<For each={right()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</box>
</Show>
</box>

View File

@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import type { Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
import { WorkspaceID } from "@/control-plane/schema"
import { Heap } from "@/cli/heap"
await Log.init({
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStreams = new Map<string, AbortController>()
function startEventStream(directory: string) {
const id = crypto.randomUUID()
const abort = new AbortController()
const signal = abort.signal
eventStreams.set(id, abort)
async function run() {
while (!signal.aborted) {
const shouldReconnect = await Instance.provide({
directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
Rpc.emit("event", {
type: "server.connected",
properties: {},
} satisfies Event)
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
signal.removeEventListener("abort", onAbort)
unsub()
resolve(value)
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", {
id,
event: event as Event,
})
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
})
const onAbort = () => {
settle(false)
}
signal.addEventListener("abort", onAbort, { once: true })
}),
}).catch((error) => {
Log.Default.error("event stream subscribe error", {
error: error instanceof Error ? error.message : error,
})
return false
})
if (!shouldReconnect || signal.aborted) {
break
}
if (!signal.aborted) {
await sleep(250)
}
}
}
run().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
return id
}
function stopEventStream(id: string) {
const abortController = eventStreams.get(id)
if (!abortController) return
abortController.abort()
eventStreams.delete(id)
}
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
@@ -167,19 +83,9 @@ export const rpc = {
async reload() {
await Config.invalidate(true)
},
async subscribe(input: { directory: string | undefined }) {
return startEventStream(input.directory || process.cwd())
},
async unsubscribe(input: { id: string }) {
stopEventStream(input.id)
},
async shutdown() {
Log.Default.info("worker shutting down")
for (const id of [...eventStreams.keys()]) {
stopEventStream(id)
}
await Instance.disposeAll()
if (server) await server.stop(true)
},

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

@@ -0,0 +1,22 @@
import { LocalContext } from "../util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: string
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (err) {
return undefined
}
},
}

View File

@@ -5,7 +5,9 @@ import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
@@ -14,6 +16,18 @@ import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
@@ -27,13 +41,9 @@ export namespace Workspace {
message: z.string(),
}),
),
Status: BusEvent.define("workspace.status", ConnectionStatus),
}
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return {
id: row.id,
@@ -85,6 +95,9 @@ export namespace Workspace {
})
await adaptor.create(config)
startSync(info)
return info
})
@@ -92,18 +105,24 @@ export namespace Workspace {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
const space = fromRow(row)
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
const adaptor = await getAdaptor(row.type)
adaptor.remove(info)
@@ -111,58 +130,100 @@ export namespace Workspace {
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, stop: AbortSignal) {
while (!stop.aborted) {
async function workspaceEventLoop(space: Info, signal: AbortSignal) {
log.info("starting sync: " + space.id)
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type)
const target = await Promise.resolve(adaptor.target(space))
const target = await adaptor.target(space)
if (target.type === "local") {
return
}
if (target.type === "local") return
const baseURL = String(target.url).replace(/\/?$/, "/")
const res = await fetch(new URL(baseURL + "/event"), {
method: "GET",
signal: stop,
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => {
setStatus(space.id, "error", String(err))
return undefined
})
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
if (!res.ok || !res.body) {
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response")
await sleep(1000)
continue
}
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
await parseSSE(res.body, stop, (event) => {
GlobalBus.emit("event", {
directory: space.id,
payload: event,
})
try {
if (!event.type.startsWith("server.")) {
SyncEvent.replay(event)
}
} catch (err) {
log.warn("failed to replay sync event", {
workspaceID: space.id,
error: err,
})
}
})
// Wait 250ms and retry if SSE connection fails
setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id)
await sleep(250)
}
}
export function startSyncing(project: Project.Info) {
const stop = new AbortController()
const spaces = list(project).filter((space) => space.type !== "worktree")
function startSync(space: Info) {
if (space.type === "worktree") {
void Filesystem.exists(space.directory!).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
spaces.forEach((space) => {
void workspaceEventLoop(space, stop.signal).catch((error) => {
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
})
if (aborts.has(space.id)) return
const abort = new AbortController()
aborts.set(space.id, abort)
setStatus(space.id, "disconnected")
void workspaceEventLoop(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", {
workspaceID: space.id,
error,
})
})
}
return {
async stop() {
stop.abort()
},
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
}
}

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,6 +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 = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})

View File

@@ -1,8 +1,10 @@
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 { InstanceRef } from "./instance-ref"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
const TypeId = "~opencode/InstanceState"
@@ -16,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
}
@@ -28,6 +30,10 @@ export namespace InstanceState {
return (yield* InstanceRef) ?? Instance.current
})
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>(
@@ -42,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,23 +1,25 @@
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 { InstanceRef } from "./instance-ref"
import { LocalContext } from "@/util/local-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { Observability } from "./oltp"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx)
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"
@@ -11,7 +11,6 @@ import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
@@ -338,12 +337,13 @@ 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,
Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() =>
@@ -410,6 +410,10 @@ export namespace File {
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
})
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () {
yield* ensure()
})
@@ -417,100 +421,87 @@ export namespace File {
const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => {
const diffOutput = (
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
const diffOutput = yield* gitText([
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--numstat",
"HEAD",
])
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
).text()
const changed: File.Info[] = []
if (diffOutput.trim()) {
for (const line of diffOutput.trim().split("\n")) {
const [added, removed, file] = line.split("\t")
changed.push({
path: file,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
}
const untrackedOutput = (
await Git.run(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
],
{
cwd: Instance.directory,
},
)
).text()
const untrackedOutput = yield* gitText([
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
])
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
try {
const content = await Filesystem.readText(path.join(Instance.directory, file))
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
} catch {
continue
}
}
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs
.readFileString(path.join(Instance.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({
path: file,
added: content.split("\n").length,
removed: 0,
status: "added",
})
}
}
const deletedOutput = (
await Git.run(
[
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
],
{
cwd: Instance.directory,
},
)
).text()
const deletedOutput = yield* gitText([
"-c",
"core.fsmonitor=false",
"-c",
"core.quotepath=false",
"diff",
"--name-only",
"--diff-filter=D",
"HEAD",
])
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) {
changed.push({
path: file,
added: 0,
removed: 0,
status: "deleted",
})
}
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
}
})
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
}
})
})
const read = Effect.fn("File.read")(function* (file: string) {
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
@@ -558,27 +549,19 @@ export namespace File {
)
if (Instance.project.vcs === "git") {
return yield* Effect.promise(async (): Promise<File.Content> => {
let diff = (
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
if (!diff.trim()) {
diff = (
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
})
).text()
}
if (diff.trim()) {
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text", content, patch, diff: formatPatch(patch) }
}
return { type: "text", content }
})
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
const original = yield* git.show(Instance.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
}
return { type: "text" as const, content }
}
return { type: "text" as const, content }
@@ -660,7 +643,7 @@ export namespace File {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -3,10 +3,17 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod"
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"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
input.signal?.throwIfAborted()
}
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))

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,12 +65,13 @@ 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,
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
@@ -131,11 +132,9 @@ export namespace FileWatcher {
}
if (Instance.project.vcs === "git") {
const result = yield* Effect.promise(() =>
Git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
}),
)
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree,
})
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -161,7 +160,7 @@ export namespace FileWatcher {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

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,
@@ -51,6 +50,13 @@ export namespace Format {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) {
delete formatters[name]
continue
@@ -186,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

@@ -105,17 +105,7 @@ export namespace LSPServer {
if (!tsserver) return
const bin = await Npm.which("typescript-language-server")
if (!bin) return
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
if (
!(await pathExists(path.join(root, "tsconfig.json"))) &&
!(await pathExists(path.join(root, "jsconfig.json")))
) {
args.push("--ignore-node-modules")
}
const proc = spawn(bin, args, {
const proc = spawn(bin, ["--stdio"], {
cwd: root,
env: {
...process.env,

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,8 +3,9 @@ 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"
export interface InstanceContext {
@@ -13,26 +14,16 @@ 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 = {
all: undefined as Promise<void> | undefined,
}
function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function emitDisposed(directory: string) {}
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
input.project && input.worktree
@@ -93,17 +84,19 @@ export const Instance = {
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* 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
@@ -131,15 +124,39 @@ export const Instance = {
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
emit(directory)
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next
},
async dispose() {
const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory)
emit(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
},
async disposeAll() {
if (disposal.all) return disposal.all

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 }
@@ -137,6 +137,8 @@ export namespace Project {
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
project: data.id,
payload: { type: Event.Updated.type, properties: data },
}),
)

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

@@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceRoutes } from "./instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -26,6 +29,23 @@ function local(method: string, path: string) {
return false
}
function getSessionID(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
return SessionID.make(id)
}
async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await Session.get(id).catch(() => undefined)
return session?.workspaceID
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
const routes = lazy(() => InstanceRoutes(upgrade))
@@ -42,13 +62,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
)
const url = new URL(c.req.url)
const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
// TODO: If session is being routed, force it to lookup the
// project/workspace
const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
// If no workspace is provided we use the "project" workspace
if (!workspaceParam) {
// If no workspace is provided we use the project
if (!workspaceID) {
return Instance.provide({
directory,
init: InstanceBootstrap,
@@ -58,9 +77,19 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
})
}
const workspaceID = WorkspaceID.make(workspaceParam)
const workspace = await Workspace.get(workspaceID)
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) {
// Special-case deleting a session in case user's data in a
// weird state. Allow them to forcefully delete a synced session
// even if the remote workspace is not in their data.
//
// The lets the `DELETE /session/:id` endpoint through and we've
// made sure that it will run without an instance
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
return routes().fetch(c.req.raw, c.env)
}
return new Response(`Workspace not found: ${workspaceID}`, {
status: 500,
headers: {
@@ -73,12 +102,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const target = await adaptor.target(workspace)
if (target.type === "local") {
return Instance.provide({
directory: target.directory,
init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
return WorkspaceContext.provide({
workspaceID: WorkspaceID.make(workspaceID),
fn: () =>
Instance.provide({
directory: target.directory,
init: InstanceBootstrap,
async fn() {
return routes().fetch(c.req.raw, c.env)
},
}),
})
}

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"
@@ -105,6 +107,8 @@ export const GlobalRoutes = lazy(() =>
z
.object({
directory: z.string(),
project: z.string().optional(),
workspace: z.string().optional(),
payload: BusEvent.payloads(),
})
.meta({
@@ -288,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

@@ -62,6 +62,28 @@ export const WorkspaceRoutes = lazy(() =>
return c.json(Workspace.list(Instance.project))
},
)
.get(
"/status",
describeRoute({
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
operationId: "experimental.workspace.status",
responses: {
200: {
description: "Workspace status",
content: {
"application/json": {
schema: resolver(z.array(Workspace.ConnectionStatus)),
},
},
},
},
}),
async (c) => {
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
},
)
.delete(
"/:id",
describeRoute({

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,26 @@ 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 | SyncEvent.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const storage = yield* Storage.Service
const sync = yield* SyncEvent.Service
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
@@ -392,7 +399,7 @@ export namespace Session {
}
log.info("created", result)
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
yield* sync.run(Event.Created, { sessionID: result.id, info: result })
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// This only exist for backwards compatibility. We should not be
@@ -413,28 +420,39 @@ export namespace Session {
})
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
const ctx = yield* InstanceState.context
const rows = yield* db((d) =>
d
.select()
.from(SessionTable)
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
.where(and(eq(SessionTable.parent_id, parentID)))
.all(),
)
return rows.map(fromRow)
})
const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) {
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
try {
const session = yield* get(sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
}
yield* Effect.sync(() => {
SyncEvent.run(Event.Deleted, { sessionID, info: session })
SyncEvent.remove(sessionID)
})
// `remove` needs to work in all cases, such as a broken
// sessions that run cleanup. In certain cases these will
// run without any instance state, so we need to turn off
// publishing of events in that case
const hasInstance = yield* InstanceState.directory.pipe(
Effect.as(true),
Effect.catchCause(() => Effect.succeed(false)),
)
if (hasInstance) {
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: true })
} else {
yield* Effect.sync(() => SyncEvent.run(Event.Deleted, { sessionID, info: session }, { publish: false }))
}
yield* sync.remove(sessionID)
} catch (e) {
log.error(e)
}
@@ -442,19 +460,17 @@ export namespace Session {
const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg })
return msg
}).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
}),
)
yield* sync.run(MessageV2.Event.PartUpdated, {
sessionID: part.sessionID,
part: structuredClone(part),
time: Date.now(),
})
return part
}).pipe(Effect.withSpan("Session.updatePart"))
@@ -534,8 +550,7 @@ export namespace Session {
return session
})
const patch = (sessionID: SessionID, info: Patch) =>
Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info }))
const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info })
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
yield* patch(sessionID, { time: { updated: Date.now() } })
@@ -576,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 }) {
@@ -592,12 +607,10 @@ export namespace Session {
sessionID: SessionID
messageID: MessageID
}) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
}),
)
yield* sync.run(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: input.messageID,
})
return input.messageID
})
@@ -606,13 +619,11 @@ export namespace Session {
messageID: MessageID
partID: PartID
}) {
yield* Effect.sync(() =>
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
}),
)
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID: input.sessionID,
messageID: input.messageID,
partID: input.partID,
})
return input.partID
})
@@ -626,6 +637,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,
@@ -647,11 +669,16 @@ 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),
Layer.provide(SyncEvent.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -823,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,
@@ -40,6 +40,7 @@ export namespace SessionRevert {
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const sync = yield* SyncEvent.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
@@ -123,7 +124,7 @@ export namespace SessionRevert {
remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
yield* sync.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
@@ -135,7 +136,7 @@ export namespace SessionRevert {
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
@@ -158,6 +159,7 @@ export namespace SessionRevert {
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
),
)

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,
@@ -24,20 +24,19 @@ export namespace SessionShare {
const session = yield* Session.Service
const shareNext = yield* ShareNext.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) {
const conf = yield* cfg.get()
if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration")
const result = yield* shareNext.create(sessionID)
yield* Effect.sync(() =>
SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }),
)
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } })
return result
})
const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) {
yield* shareNext.remove(sessionID)
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
yield* sync.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })
})
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
@@ -57,6 +56,7 @@ export namespace SessionShare {
Layer.provide(ShareNext.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)

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))
}
}

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