Compare commits

..

67 Commits

Author SHA1 Message Date
Kit Langton
80f1f1b5b8 feat: enable type-aware no-floating-promises rule, fix all 177 violations (#22741) 2026-04-15 23:27:32 -04:00
Kit Langton
343a564183 feat: unwrap 11 util namespaces to flat exports + barrel (#22739) 2026-04-15 23:15:58 -04:00
Kit Langton
b0eae5e12f feat: bridge permission and provider auth routes behind OPENCODE_EXPERIMENTAL_HTTPAPI (#22736) 2026-04-15 23:02:48 -04:00
Kit Langton
702f741267 feat: enable oxlint suspicious category, fix 24 violations (#22727) 2026-04-16 02:53:10 +00:00
Kit Langton
665a843086 feat: unwrap Archive namespace to flat exports + barrel (#22722) 2026-04-16 02:52:34 +00:00
Kit Langton
1508196c0f feat: bridge question routes from Hono to Effect HttpApi (#22718) 2026-04-15 22:50:22 -04:00
opencode-agent[bot]
f6243603f8 chore: generate 2026-04-16 02:46:39 +00:00
Kit Langton
379e40d772 feat: unwrap InstanceState + EffectBridge namespaces to flat exports + barrel (#22721) 2026-04-16 02:45:45 +00:00
Kit Langton
6c7e9f6f3a refactor: migrate Effect call sites from Flock to EffectFlock (#22688) 2026-04-16 02:39:59 +00:00
opencode-agent[bot]
48f88af9aa chore: update nix node_modules hashes 2026-04-16 02:39:40 +00:00
Kit Langton
60c927cf4f feat: unwrap Pty namespace to flat exports + barrel (#22719) 2026-04-16 02:21:46 +00:00
opencode-agent[bot]
069cef8a44 chore: generate 2026-04-16 02:18:58 +00:00
Kit Langton
cf423d2769 fix: remove 10 unused type-only imports and declarations (#22696) 2026-04-16 02:17:59 +00:00
Kit Langton
62ddb9d3ad feat: unwrap uskill namespace to flat exports + barrel (#22714) 2026-04-16 02:17:19 +00:00
Kit Langton
0b975b01fb feat: unwrap ugit namespace to flat exports + barrel (#22704) 2026-04-16 02:16:42 +00:00
Kit Langton
bb90aa6cb2 feat: unwrap uworktree namespace to flat exports + barrel (#22717) 2026-04-16 02:16:17 +00:00
Kit Langton
ce4e47a2e3 feat: unwrap uformat namespace to flat exports + barrel (#22703) 2026-04-16 02:16:01 +00:00
Kit Langton
e3677c2ba2 feat: unwrap upatch namespace to flat exports + barrel (#22709) 2026-04-16 02:15:58 +00:00
Kit Langton
a653a4b887 feat: unwrap usync namespace to flat exports + barrel (#22716) 2026-04-16 02:15:46 +00:00
Kit Langton
f7edffc11a feat: unwrap uglobal namespace to flat exports + barrel (#22705) 2026-04-16 02:15:36 +00:00
Kit Langton
dc16488bd7 feat: unwrap uide namespace to flat exports + barrel (#22706) 2026-04-16 02:15:21 +00:00
Kit Langton
d7a072dd46 feat: unwrap usnapshot namespace to flat exports + barrel (#22715) 2026-04-16 02:15:20 +00:00
Kit Langton
5ae91aa810 feat: unwrap uplugin namespace to flat exports + barrel (#22711) 2026-04-16 02:15:19 +00:00
Kit Langton
18538e359b feat: unwrap usession namespace to flat exports + barrel (#22713) 2026-04-16 02:15:17 +00:00
Kit Langton
47577ae857 feat: unwrap upermission namespace to flat exports + barrel (#22710) 2026-04-16 02:14:59 +00:00
Kit Langton
d22b5f026d feat: unwrap unpm namespace to flat exports + barrel (#22708) 2026-04-16 02:14:44 +00:00
Kit Langton
26cdbc20b2 feat: unwrap ufile namespace to flat exports + barrel (#22702) 2026-04-16 02:14:37 +00:00
Kit Langton
360d8dd940 feat: unwrap uinstallation namespace to flat exports + barrel (#22707) 2026-04-16 02:14:34 +00:00
Kit Langton
426815a829 feat: unwrap ucommand namespace to flat exports + barrel (#22700) 2026-04-16 02:14:18 +00:00
Kit Langton
c6286d1bb9 feat: unwrap uenv namespace to flat exports + barrel (#22701) 2026-04-16 02:14:14 +00:00
Kit Langton
710c81984a feat: unwrap uauth namespace to flat exports + barrel (#22699) 2026-04-16 02:13:56 +00:00
Kit Langton
a1dbfb5967 feat: unwrap uaccount namespace to flat exports + barrel (#22698) 2026-04-16 02:13:33 +00:00
opencode-agent[bot]
64cc4623b5 chore: generate 2026-04-16 02:08:47 +00:00
Kit Langton
5eae926846 add experimental provider auth HttpApi slice (#22389) 2026-04-16 02:07:42 +00:00
Kit Langton
cce05c1665 fix: clean up 49 unused variables, catch params, and stale imports (#22695) 2026-04-16 02:01:53 +00:00
Kit Langton
34213d4446 fix: delete 9 dead functions with zero callers (#22697) 2026-04-16 02:01:02 +00:00
opencode-agent[bot]
70aeebf2df chore: generate 2026-04-16 01:57:23 +00:00
Kit Langton
d6b14e2467 fix: prefix 32 unused parameters with underscore (#22694) 2026-04-15 21:56:23 -04:00
Kit Langton
6625766350 feat: unwrap MCP namespace to flat exports + barrel (#22693) 2026-04-16 01:56:02 +00:00
opencode-agent[bot]
7baf998752 chore: generate 2026-04-16 01:45:44 +00:00
Kit Langton
1d81335ab5 feat: unwrap Provider namespace + improved automation script (#22690) 2026-04-15 21:44:46 -04:00
Kit Langton
f7d4665e40 fix: resolve oxlint warnings — suppress false positives, remove unused imports (#22687) 2026-04-15 21:33:54 -04:00
Kit Langton
bbdbc107ae feat: unwrap Config namespace to flat exports + barrel (#22689) 2026-04-15 21:26:24 -04:00
Kit Langton
0fb0135e51 refactor: remove makeRuntime facades from File and Ripgrep (#22513) 2026-04-15 21:22:18 -04:00
Kit Langton
02f2cf439e feat: namespace → flat export migration (Bus proof-of-concept) (#22685) 2026-04-16 01:18:36 +00:00
Kit Langton
6d42f97644 fix: revert "core: move plugin initialisation to config layer override" (#22686) 2026-04-15 21:14:39 -04:00
Aiden Cline
307251bf3c fix: bash memory usage (#22660) 2026-04-15 20:09:06 -05:00
James Long
074ef032ee feat(core): add fence to make all methods strongly consistent when syncing (#22679) 2026-04-15 21:04:37 -04:00
Kit Langton
4ca809ef4e fix(session): retry 5xx server errors even when isRetryable is unset (#22511) 2026-04-16 00:58:48 +00:00
Kit Langton
a147ad68e6 feat(shared): add Effect-idiomatic file lock (EffectFlock) (#22681) 2026-04-16 00:55:14 +00:00
opencode-agent[bot]
ac2fa668cf chore: generate 2026-04-16 00:46:18 +00:00
Kit Langton
3d6f90cb53 feat: add oxlint with correctness defaults (#22682) 2026-04-15 20:45:19 -04:00
Carlo Wood
a554fad232 fix(tui): Don't overwrite the agent that was specified on the command line (#20554) 2026-04-15 18:41:35 -05:00
Kit Langton
4dd0d1f67e refactor(opencode): use AppFileSystem path helpers (#22637) 2026-04-15 19:36:30 -04:00
Kit Langton
672ee28635 fix(opencode): avoid org lookup during config startup (#22670) 2026-04-15 19:20:25 -04:00
David Hill
e16589f8b5 tweak(ui): session spacing (#20839)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-04-15 18:58:05 -04:00
opencode-agent[bot]
83e257b468 chore: generate 2026-04-15 22:45:54 +00:00
Brendan Allan
916131be19 core: move plugin intialisation to config layer override (#22620) 2026-04-15 22:44:55 +00:00
Ariane Emory
d2ea6700aa fix(core): Remove dead code and documentation related to the obsolete list tool. (#22672) 2026-04-15 17:44:53 -05:00
Kit Langton
6bed7d469d feat(opencode): improve telemetry tracing and request spans (#22653) 2026-04-15 17:32:56 -04:00
opencode-agent[bot]
3b75f16119 chore: generate 2026-04-15 21:29:10 +00:00
Kit Langton
250e30bc7d add experimental permission HttpApi slice (#22385) 2026-04-15 21:28:01 +00:00
Aiden Cline
e83b22159d tweak: ensure auto continuing compaction is tracked as agent initiated for github copilot (#22567)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 15:50:33 -05:00
Aiden Cline
348a84969d fix: ensure tool_use is always followed by tool_result (#22646)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-15 14:56:45 -05:00
opencode-agent[bot]
8ba4799b3e chore: update nix node_modules hashes 2026-04-15 17:38:21 +00:00
Kit Langton
9640d889ba fix: register OTel context manager so AI SDK spans thread into Effect traces (#22645) 2026-04-15 16:35:14 +00:00
Dax
4ae7c77f8a migrate: move flock and hash utilities to shared package (#22640) 2026-04-15 15:50:24 +00:00
559 changed files with 17381 additions and 14733 deletions

View File

@@ -1,25 +0,0 @@
import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
let seen = false
const plugin: TuiPluginModule & { id: string } = {
id: "local.config-once-toast",
async tui(api) {
if (seen) return
const cfg = api.state.config
if (cfg.plugin !== undefined && !Array.isArray(cfg.plugin)) {
throw new Error("Invalid config: plugin must be an array")
}
const mdl = typeof cfg.model === "string" && cfg.model.trim() ? cfg.model : "default"
seen = true
api.ui.toast({
title: "Config check",
message: `This is a 1 time toast, validating ur config (model: ${mdl})`,
variant: "info",
})
},
}
export default plugin

View File

@@ -1,7 +1,6 @@
{
"$schema": "https://opencode.ai/tui.json",
"plugin": [
"./plugins/tui-config-once-toast.tsx",
[
"./plugins/tui-smoke.tsx",
{

42
.oxlintrc.json Normal file
View File

@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
"categories": {
"suspicious": "warn"
},
"rules": {
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
"require-yield": "off",
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
"no-unassigned-vars": "off",
// SolidJS tracks reactive deps by reading properties inside createEffect
"no-unused-expressions": "off",
// Intentional control char matching (ANSI escapes, null byte sanitization)
"no-control-regex": "off",
// SST and plugin tools require triple-slash references
"triple-slash-reference": "off",
// Suspicious category: suppress noisy rules
// Effect's nested function* closures inherently shadow outer scope
"no-shadow": "off",
// Namespace-heavy codebase makes this too noisy
"unicorn/consistent-function-scoping": "off",
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
"unicorn/no-array-sort": "off",
"unicorn/no-array-reverse": "off",
// Not relevant — this isn't a DOM event handler codebase
"unicorn/prefer-add-event-listener": "off",
// Bundler handles module resolution
"unicorn/require-module-specifiers": "off",
// postMessage target origin not relevant for this codebase
"unicorn/require-post-message-target-origin": "off",
// Side-effectful constructors are intentional in some places
"no-new": "off",
// Type-aware: catch unhandled promises
"typescript/no-floating-promises": "warn"
},
"options": {
"typeAware": true
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts"]
}

View File

@@ -11,36 +11,10 @@
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
### Naming
Prefer single word names for variables and functions. Only use multiple words if necessary.
### Naming Enforcement (Read This)
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
- Use single word names by default for new locals, params, and helper functions.
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
```ts
// Good
const foo = 1
function journal(dir: string) {}
// Bad
const fooBar = 1
function prepareJournal(dir: string) {}
```
Reduce total variable count by inlining when a value is only used once.
```ts

View File

@@ -19,6 +19,8 @@
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",
@@ -357,8 +359,9 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
@@ -503,17 +506,6 @@
"typescript": "catalog:",
},
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.4.6",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.6",
@@ -527,9 +519,11 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"semver": "catalog:",
"xdg-basedir": "5.1.0",
"zod": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/semver": "catalog:",
},
},
@@ -1563,8 +1557,6 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
@@ -1689,6 +1681,56 @@
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.96.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0fI0P0W7bSO/GCP/N5dkmtB9vBqCA4ggo1WmXTnxNJVmFFOtcA1vYm1I9jl8fxo+sucW2WnlpnI4fjKdo3JKxA=="],
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P20j3MLqfwIT+94qGU3htC7dWp4pXGZW1p1p7FRUzu1aopq7c9nPCgf0W/WjktqQ57+iuTq9mbSlwWinl6+H1A=="],
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.21.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-81TmmuBcPedEA0MwRmObuQuXnCprS1UiHQWGe7pseqNAJzUWXeAPrayqKTACX92VpruJI+yvY0XJrFp11PpcTA=="],
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.21.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sbjBr6zDduX8rNO0PTjhf7VYLCPWqdijWiMPp8e10qu6Tam1GdaVLaLlX8QrNupTgglO1GvqqgY/jcacWL8a6g=="],
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.21.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jNrOcy53R5TJQfrK444Cm60bW9437xDoxPbm3AdvFSo/fhdFMllawc7uZC2Wzr+EAjTkW13K8R4QHzsUdBG9fQ=="],
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.21.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWeRxJJILDE4b9UqHEWGBxcBc1TUS6zWHhxcyxTZMwf4q3wdKeu0OHYAcwLGJzoSjEIf6FTjyfPiRNil2oqsdg=="],
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.21.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ob9AA9teI8ckPo1whV1smLr5NrqwgBv/8boDbK0YZG+fKgNGRwr1hBj1ORgFWOQaUBv+5njp5A0RAfJJjQ95QQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="],
"@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ=="],
"@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw=="],
@@ -4069,6 +4111,10 @@
"oxc-transform": ["oxc-transform@0.96.0", "", { "optionalDependencies": { "@oxc-transform/binding-android-arm64": "0.96.0", "@oxc-transform/binding-darwin-arm64": "0.96.0", "@oxc-transform/binding-darwin-x64": "0.96.0", "@oxc-transform/binding-freebsd-x64": "0.96.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-transform/binding-linux-arm-musleabihf": "0.96.0", "@oxc-transform/binding-linux-arm64-gnu": "0.96.0", "@oxc-transform/binding-linux-arm64-musl": "0.96.0", "@oxc-transform/binding-linux-riscv64-gnu": "0.96.0", "@oxc-transform/binding-linux-s390x-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-gnu": "0.96.0", "@oxc-transform/binding-linux-x64-musl": "0.96.0", "@oxc-transform/binding-wasm32-wasi": "0.96.0", "@oxc-transform/binding-win32-arm64-msvc": "0.96.0", "@oxc-transform/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dQPNIF+gHpSkmC0+Vg9IktNyhcn28Y8R3eTLyzn52UNymkasLicl3sFAtz7oEVuFmCpgGjaUTKkwk+jW2cHpDQ=="],
"oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
"oxlint-tsgolint": ["oxlint-tsgolint@0.21.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.21.0", "@oxlint-tsgolint/darwin-x64": "0.21.0", "@oxlint-tsgolint/linux-arm64": "0.21.0", "@oxlint-tsgolint/linux-x64": "0.21.0", "@oxlint-tsgolint/win32-arm64": "0.21.0", "@oxlint-tsgolint/win32-x64": "0.21.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-HiWPhANwRnN1pZJQ2SgNB3WRR+1etLJHmRzQ/MJhyINsEIaOUCjxhlXJKbEaVUwdnyXwRWqo/P9Fx21lz0/mSg=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-defer": ["p-defer@3.0.0", "", {}, "sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw=="],

View File

@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
})
connected = true
break
} catch (e) {}
} catch {}
await sleep(300)
} while (retry++ < 30)
@@ -513,7 +513,7 @@ async function subscribeSessionEvents() {
const decoder = new TextDecoder()
let text = ""
;(async () => {
void (async () => {
while (true) {
try {
const { done, value } = await reader.read()
@@ -542,7 +542,7 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
}
if (part.type === "text") {
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
if (evt.properties.info.id !== session.id) continue
session = evt.properties.info
}
} catch (e) {
} catch {
// Ignore parse errors
}
}
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
async function summarize(response: string) {
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
} catch {
if (isScheduleEvent()) {
return "Scheduled task changes"
}
@@ -776,7 +776,7 @@ async function assertPermissions() {
console.log(` permission: ${permission}`)
} catch (error) {
console.error(`Failed to check permissions: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)

View File

@@ -1,9 +1,9 @@
import { SECRET } from "./secret"
import { domain, shortDomain } from "./stage"
import { shortDomain } from "./stage"
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
const teams = new sst.cloudflare.x.SolidStart("Teams", {
new sst.cloudflare.x.SolidStart("Teams", {
domain: shortDomain,
path: "packages/enterprise",
buildCommand: "bun run build:cloudflare",

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-3kpnjBg7AQanyDGTOFdYBFvo9O9Rfnu0Wmi8bY5LpEI=",
"aarch64-linux": "sha256-8rQ+SNUiSpA2Ea3NrYNGopHQsnY7Y8qBsXCqL6GMt24=",
"aarch64-darwin": "sha256-OASMkW5hnXucV6lSmxrQo73lGSEKN4MQPNGNV0i7jdo=",
"x86_64-darwin": "sha256-CmHqXlm8wnLcwSSK0ghxAf+DVurEltMaxrUbWh9/ZGE="
"x86_64-linux": "sha256-VIgTxIjmZ4Bfwwdj/YFmRJdBpPHYhJSY31kh06EXX+0=",
"aarch64-linux": "sha256-9118AS1ED0nrliURgZYBRuF/18RqXpUouhYJRlZ6jeA=",
"aarch64-darwin": "sha256-ppo3MfSIGKQHJCdYEZiLFRc61PtcJ9J0kAXH1pNIonA=",
"x86_64-darwin": "sha256-m+CZSOglBCTfNzbdBX6hXdDqqOzHNMzAddVp6BZVDtU="
}
}

View File

@@ -11,6 +11,7 @@
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
@@ -85,6 +86,8 @@
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",
"oxlint": "1.60.0",
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.18.10",

View File

@@ -180,8 +180,8 @@ describe("SerializeAddon", () => {
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origFg = origLine!.getCell(0)!.getFgColor()
const origBg = origLine!.getCell(0)!.getBgColor()
const _origFg = origLine!.getCell(0)!.getFgColor()
const _origBg = origLine!.getCell(0)!.getBgColor()
expect(origLine!.getCell(0)!.isBold()).toBe(1)
const serialized = addon.serialize({ range: { start: 0, end: 0 } })

View File

@@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._allRows = Array.from<string>({ length: rows })
this._allRowSeparators = Array.from<string>({ length: rows })
this._rowIndex = 0
this._currentRow = ""

View File

@@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Duration, Effect } from "effect"
import { Effect } from "effect"
import {
type Component,
createMemo,
@@ -156,11 +156,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
)
}
const effectMinDuration =
(duration: Duration.Input) =>
<A, E, R>(e: Effect.Effect<A, E, R>) =>
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
@@ -202,12 +197,12 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") healthCheckActions.refetch()
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
healthCheckActions.refetch()
void healthCheckActions.refetch()
}}
/>
}

View File

@@ -327,7 +327,7 @@ export function DialogConnectProvider(props: { provider: string }) {
if (loading()) return
if (methods().length === 1) {
auto = true
selectMethod(0)
void selectMethod(0)
}
})
@@ -373,7 +373,7 @@ export function DialogConnectProvider(props: { provider: string }) {
key={(m) => m?.label}
onSelect={async (selected, index) => {
if (!selected) return
selectMethod(index)
void selectMethod(index)
}}
>
{(i) => (

View File

@@ -348,8 +348,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const open = (path: string) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)

View File

@@ -344,7 +344,7 @@ export function DialogSelectServer() {
createEffect(() => {
items()
refreshHealth()
void refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
@@ -498,7 +498,7 @@ export function DialogSelectServer() {
async function handleRemove(url: ServerConnection.Key) {
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
platform.setDefaultServer?.(null)
void platform.setDefaultServer?.(null)
}
}
@@ -536,7 +536,7 @@ export function DialogSelectServer() {
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) select(x)
if (x) void select(x)
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"

View File

@@ -14,7 +14,6 @@ import {
Switch,
untrack,
type ComponentProps,
type JSXElement,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -149,7 +148,7 @@ const FileTreeNode = (
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...(local.classList ?? {}),
...local.classList,
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}

View File

@@ -212,9 +212,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)
void tabs().open(tab)
tabs().setActive(tab)
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
}
const recent = createMemo(() => {
@@ -1139,7 +1139,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (working()) {
abort()
void abort()
event.preventDefault()
event.stopPropagation()
return
@@ -1205,7 +1205,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (working()) {
abort()
void abort()
event.preventDefault()
}
return
@@ -1245,7 +1245,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) {
return
}
handleSubmit(event)
void handleSubmit(event)
}
}

View File

@@ -295,7 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const mode = input.mode()
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
if (input.working()) abort()
if (input.working()) void abort()
return
}

View File

@@ -24,7 +24,7 @@ function openSessionContext(args: {
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
args.tabs.open("context")
void args.tabs.open("context")
args.tabs.setActive("context")
}

View File

@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/shared/util/path"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"

View File

@@ -44,7 +44,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const close = () => {
const count = terminal.all().length
terminal.close(props.terminal.id)
void terminal.close(props.terminal.id)
if (count === 1) {
props.onClose?.()
}

View File

@@ -415,7 +415,7 @@ export const Terminal = (props: TerminalProps) => {
if (local.autoFocus !== false) focusTerminal()
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
void document.fonts.ready.then(scheduleFit)
}
const onResize = t.onResize((size) => {
@@ -634,7 +634,7 @@ export const Terminal = (props: TerminalProps) => {
tabIndex={-1}
style={{ "background-color": terminalColors().background }}
classList={{
...(local.classList ?? {}),
...local.classList,
"select-text": true,
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class,

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"

View File

@@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (started) return run
started = true
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()

View File

@@ -237,7 +237,7 @@ function createGlobalSync() {
})
sessionLoads.set(directory, promise)
promise.finally(() => {
void promise.finally(() => {
sessionLoads.delete(directory)
children.unpin(directory)
})
@@ -273,7 +273,7 @@ function createGlobalSync() {
})()
booting.set(directory, promise)
promise.finally(() => {
void promise.finally(() => {
booting.delete(directory)
children.unpin(directory)
})
@@ -317,7 +317,7 @@ function createGlobalSync() {
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
void sdkFor(directory)
.lsp.status()
.then((x) => {
setStore("lsp", x.data ?? [])
@@ -359,13 +359,13 @@ function createGlobalSync() {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
void globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
void globalSDK.event.start()
}, 0)
}
void bootstrap()

View File

@@ -65,22 +65,6 @@ function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
requestFailedTitle: string

View File

@@ -243,8 +243,8 @@ export function createChildStoreManager(input: {
const cached = metaCache.get(directory)
if (!cached) return
const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon
const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands
const next = {
...previous,
...patch,

View File

@@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) {
}
} finally {
running = false
// oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused
if (input.paused()) return
if (root || queued.size) schedule()
}

View File

@@ -8,7 +8,6 @@ import type {
Part,
Path,
PermissionRequest,
Project,
ProviderListResponse,
QuestionRequest,
Session,

View File

@@ -344,7 +344,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...prev, ...next }))
prune(keep)
},
})
@@ -399,7 +399,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
local?.icon?.color !== undefined
const base = {
...(metadata ?? {}),
...metadata,
...project,
icon: {
url: metadata?.icon?.url,
@@ -582,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
void globalSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {

View File

@@ -117,7 +117,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
entry?.value.clear()
}
removePersisted(Persist.workspace(dir, "terminal"), platform)
void removePersisted(Persist.workspace(dir, "terminal"), platform)
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
@@ -126,7 +126,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
for (const key of legacy) {
removePersisted({ key }, platform)
void removePersisted({ key }, platform)
}
}

View File

@@ -1,7 +1,5 @@
import { dict as en } from "./en"
type Keys = keyof typeof en
export const dict = {
"command.category.suggested": "추천",
"command.category.view": "보기",

View File

@@ -704,7 +704,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
const active = new Set(visibleSessionDirs())
for (const directory of [...prefetchedByDir.keys()]) {
for (const directory of prefetchedByDir.keys()) {
if (active.has(directory)) continue
prefetchedByDir.delete(directory)
}
@@ -956,7 +956,7 @@ export default function Layout(props: ParentProps) {
// warm up child store to prevent flicker
globalSync.child(target.worktree)
openProject(target.worktree)
void openProject(target.worktree)
}
function navigateSessionByUnseen(offset: number) {
@@ -1094,7 +1094,7 @@ export default function Layout(props: ParentProps) {
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) archiveSession(session)
if (session) void archiveSession(session)
},
},
{
@@ -1360,11 +1360,11 @@ export default function Layout(props: ParentProps) {
if (!server.isLocal()) return
for (const directory of collectOpenProjectDeepLinks(urls)) {
openProject(directory)
void openProject(directory)
}
for (const link of collectNewSessionDeepLinks(urls)) {
openProject(link.directory, false)
void openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
@@ -1453,11 +1453,11 @@ export default function Layout(props: ParentProps) {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
for (const directory of result) {
openProject(directory, false)
void openProject(directory, false)
}
navigateToProject(result[0])
void navigateToProject(result[0])
} else if (result) {
openProject(result)
void openProject(result)
}
}
@@ -1825,7 +1825,7 @@ export default function Layout(props: ParentProps) {
const next = new Set(dirs)
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
globalSync.project.loadSessions(directory)
void globalSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
@@ -2110,7 +2110,7 @@ export default function Layout(props: ParentProps) {
onSave={(next) => {
const item = project()
if (!item) return
renameProject(item, next)
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
@@ -2242,7 +2242,7 @@ export default function Layout(props: ParentProps) {
onClick={() => {
const item = project()
if (!item) return
createWorkspace(item)
void createWorkspace(item)
}}
>
{language.t("workspace.new")}

View File

@@ -277,7 +277,7 @@ const WorkspaceSessionList = (props: {
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
size="large"
onClick={(e: MouseEvent) => {
props.loadMore()
void props.loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>

View File

@@ -433,7 +433,6 @@ export default function Page() {
const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
const canReview = createMemo(() => !!sync.project)
const reviewTab = createMemo(() => isDesktop())
const tabState = createSessionTabs({
@@ -443,8 +442,6 @@ export default function Page() {
review: reviewTab,
hasReview: canReview,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
@@ -487,7 +484,7 @@ export default function Page() {
if (!tab) return
const path = file.pathFromTab(tab)
if (path) file.load(path)
if (path) void file.load(path)
})
createEffect(

View File

@@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus())
})
const cancelCommenting = () => {
const p = path()
if (p) file.setSelectedLines(p, null)
setNote("commenting", null)
}
let prev = {
loaded: false,
ready: false,

View File

@@ -117,7 +117,7 @@ export const createOpenReviewFile = (input: {
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) maybePromise.then(open)
if (maybePromise instanceof Promise) void maybePromise.then(open)
else open()
})
}

View File

@@ -1,4 +1,4 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"

View File

@@ -46,7 +46,9 @@ describe("runtime adapters", () => {
})
test("resolves speech recognition constructor with webkit precedence", () => {
// oxlint-disable-next-line no-extraneous-class
class SpeechCtor {}
// oxlint-disable-next-line no-extraneous-class
class WebkitCtor {}
const ctor = getSpeechRecognitionCtor({
SpeechRecognition: SpeechCtor,

View File

@@ -8,7 +8,6 @@ import { LOCALES, route } from "../src/lib/language.js"
const __dirname = dirname(fileURLToPath(import.meta.url))
const BASE_URL = config.baseUrl
const PUBLIC_DIR = join(__dirname, "../public")
const ROUTES_DIR = join(__dirname, "../src/routes")
const DOCS_DIR = join(__dirname, "../../../web/src/content/docs")
interface SitemapEntry {
@@ -106,4 +105,4 @@ async function main() {
console.log(`✓ Sitemap generated at ${outputPath}`)
}
main()
void main()

View File

@@ -1,5 +1,4 @@
import { action, useSubmission } from "@solidjs/router"
import dock from "../asset/lander/dock.png"
import { Resource } from "@opencode-ai/console-resource"
import { Show } from "solid-js"
import { useI18n } from "~/context/i18n"

View File

@@ -47,7 +47,7 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 0,
}).format(githubData()?.stars!)
}).format(githubData()?.stars)
: config.github.starsFormatted.compact,
)

View File

@@ -1,6 +1,6 @@
import { JSX } from "solid-js"
export function IconZen(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
export function IconZen(_props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg width="84" height="30" viewBox="0 0 84 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 24H6V18H18V12H24V24ZM6 18H0V12H6V18Z" fill="currentColor" fill-opacity="0.2" />
@@ -13,7 +13,7 @@ export function IconZen(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconGo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
export function IconGo(_props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg width="54" height="30" viewBox="0 0 54 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 30H0V0H24V6H6V24H18V18H12V12H24V30Z" fill="currentColor" />

View File

@@ -766,7 +766,7 @@ export default function Spotlight(props: SpotlightProps) {
}
}
initializeWebGPU()
void initializeWebGPU()
onCleanup(() => {
if (cleanupFunctionRef) {

View File

@@ -0,0 +1 @@
export {}

View File

@@ -1,7 +1,7 @@
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth"
export async function GET(input: APIEvent) {
export async function GET(_input: APIEvent) {
const session = await useAuthSession()
return Response.json(session.data)
}

View File

@@ -1,7 +1,7 @@
import { Title } from "@solidjs/meta"
import { createAsync, query, useParams } from "@solidjs/router"
import { createSignal, For, Show } from "solid-js"
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
import { useI18n } from "~/context/i18n"

View File

@@ -298,7 +298,7 @@ export default function BlackSubscribe() {
// Resolve stripe promise once
createEffect(() => {
stripePromise.then((s) => {
void stripePromise.then((s) => {
if (s) setStripe(s)
})
})

View File

@@ -3,7 +3,7 @@ import { json } from "@solidjs/router"
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
export async function GET(evt: APIEvent) {
export async function GET(_evt: APIEvent) {
return json({
data: await Database.use(async (tx) => {
const result = await tx.$count(UserTable)

View File

@@ -77,7 +77,7 @@ export default function Download() {
const handleCopyClick = (command: string) => (event: Event) => {
const button = event.currentTarget as HTMLButtonElement
navigator.clipboard.writeText(command)
void navigator.clipboard.writeText(command)
button.setAttribute("data-copied", "")
setTimeout(() => {
button.removeAttribute("data-copied")

View File

@@ -1,5 +1,5 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { createAsync, query } from "@solidjs/router"
import { Title, Meta } from "@solidjs/meta"
import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js"
//import { HttpHeader } from "@solidjs/start"

View File

@@ -31,13 +31,11 @@ export default function Home() {
const i18n = useI18n()
const language = useLanguage()
const githubData = createAsync(() => github())
const release = createMemo(() => githubData()?.release)
const handleCopyClick = (event: Event) => {
const button = event.currentTarget as HTMLButtonElement
const text = button.textContent
if (text) {
navigator.clipboard.writeText(text)
void navigator.clipboard.writeText(text)
button.setAttribute("data-copied", "")
setTimeout(() => {
button.removeAttribute("data-copied")

View File

@@ -27,7 +27,7 @@ export default function Home() {
const callback = () => {
const text = button.textContent
if (text) {
navigator.clipboard.writeText(text)
void navigator.clipboard.writeText(text)
button.setAttribute("data-copied", "")
setTimeout(() => {
button.removeAttribute("data-copied")

View File

@@ -6,7 +6,7 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import "./user-menu.css"
const logout = action(async () => {
const _logout = action(async () => {
"use server"
const auth = await useAuthSession()
const event = getRequestEvent()

View File

@@ -1,5 +1,5 @@
import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router"
import { For, Show, createEffect } from "solid-js"
import { For, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Actor } from "@opencode-ai/console-core/actor.js"

View File

@@ -90,7 +90,7 @@ export function ReloadSection() {
}
const info = billingInfo()!
setStore("show", true)
setStore("reload", info.reload ? true : true)
setStore("reload", true)
setStore("reloadAmount", info.reloadAmount.toString())
setStore("reloadTrigger", info.reloadTrigger.toString())
}

View File

@@ -1,5 +1,5 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { createAsync, query } from "@solidjs/router"
import { Title, Meta } from "@solidjs/meta"
//import { HttpHeader } from "@solidjs/start"
import zenLogoLight from "../../asset/zen-ornate-light.svg"

View File

@@ -26,14 +26,14 @@ export function createDataDumper(sessionId: string, requestId: string, projectId
const minute = timestamp.substring(10, 12)
const second = timestamp.substring(12, 14)
waitUntil(
void waitUntil(
Resource.ZenDataNew.put(
`data/${data.modelName}/${year}/${month}/${day}/${hour}/${minute}/${second}/${requestId}.json`,
JSON.stringify({ timestamp, ...data }),
),
)
waitUntil(
void waitUntil(
Resource.ZenDataNew.put(
`meta/${data.modelName}/${sessionId}/${requestId}.json`,
JSON.stringify({ timestamp, ...metadata }),

View File

@@ -144,7 +144,7 @@ export async function handler(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...providerInfo.payloadModifier,
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
@@ -345,7 +345,7 @@ export async function handler(
logger.metric({
"error.cause2": JSON.stringify(error.cause),
})
} catch (e) {}
} catch {}
}
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.

View File

@@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
let json
try {
json = JSON.parse(data.slice(6))
} catch (e) {
} catch {
return
}

View File

@@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
let json
try {
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
} catch (e) {
} catch {
return
}

View File

@@ -30,7 +30,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
},
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
modifyBody: (body: Record<string, any>, _workspaceID?: string) => {
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
@@ -49,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
let json
try {
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
} catch (e) {
} catch {
return
}
@@ -289,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
index: 0,
message: {
role: "assistant" as const,
...(content.length > 0 && content.some((c) => c.type === "text")
...(content.some((c) => c.type === "text")
? {
content: content
.filter((c) => c.type === "text")
@@ -297,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse {
.join(""),
}
: {}),
...(content.length > 0 && content.some((c) => c.type === "tool_use")
...(content.some((c) => c.type === "tool_use")
? {
tool_calls: content
.filter((c) => c.type === "tool_use")

View File

@@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
let json
try {
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
} catch (e) {
} catch {
return
}

View File

@@ -5,7 +5,7 @@ import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.j
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
export async function OPTIONS(input: APIEvent) {
export async function OPTIONS(_input: APIEvent) {
return new Response(null, {
status: 200,
headers: {

View File

@@ -6,8 +6,8 @@ export function POST(input: APIEvent) {
format: "google",
modelList: "full",
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
parseIsStream: (url: string, body: any) =>
parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
parseIsStream: (url: string, _body: any) =>
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
})

View File

@@ -1,7 +1,5 @@
import { subscribe } from "diagnostics_channel"
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]

View File

@@ -1,12 +1,10 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { BillingTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { BlackData } from "../src/black.js"
import { Actor } from "../src/actor.js"
const plan = "200"
const couponID = "JAIr0Pe1"

View File

@@ -1,7 +1,5 @@
import { subscribe } from "diagnostics_channel"
import { Billing } from "../src/billing.js"
import { and, Database, eq } from "../src/drizzle/index.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
const workspaceID = process.argv[2]

View File

@@ -1,4 +1,4 @@
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
import { Database, eq, and, sql, inArray, isNull } from "../src/drizzle/index.js"
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { AuthTable } from "../src/schema/auth.sql.js"

View File

@@ -24,11 +24,9 @@ export namespace Key {
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(
and(
...[
eq(KeyTable.workspaceID, Actor.workspace()),
isNull(KeyTable.timeDeleted),
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
],
eq(KeyTable.workspaceID, Actor.workspace()),
isNull(KeyTable.timeDeleted),
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
),
)
.orderBy(sql`${KeyTable.name} DESC`),
@@ -84,11 +82,9 @@ export namespace Key {
})
.where(
and(
...[
eq(KeyTable.id, input.id),
eq(KeyTable.workspaceID, Actor.workspace()),
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
],
eq(KeyTable.id, input.id),
eq(KeyTable.workspaceID, Actor.workspace()),
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
),
),
)

View File

@@ -0,0 +1 @@
export {}

View File

@@ -48,7 +48,7 @@ export namespace Log {
function use() {
try {
return ctx.use()
} catch (e) {
} catch {
return { tags: {} }
}
}

View File

@@ -20,7 +20,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
try {
if (path.startsWith("~")) {
const suffix = path.slice(1)
const cmd = `wslpath ${flag} \"$HOME${suffix.replace(/\"/g, '\\"')}\"`
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
return output.toString().trim()
}
@@ -28,7 +28,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
return output.toString().trim()
} catch (error) {
throw new Error(`Failed to run wslpath: ${String(error)}`)
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
}
}

View File

@@ -82,7 +82,7 @@ export function loadShellEnv(shell: string) {
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
return {
...(shell || {}),
...shell,
...env,
}
}

View File

@@ -1,5 +1,5 @@
if (location.pathname === "/loading") {
import("./loading")
void import("./loading")
} else {
import("./")
void import("./")
}

View File

@@ -410,7 +410,7 @@ const createPlatform = (): Platform => {
}
let menuTrigger = null as null | ((id: string) => void)
createMenu((id) => {
void createMenu((id) => {
menuTrigger?.(id)
})
void listenForDeepLinks()

View File

@@ -48,7 +48,7 @@ render(() => {
})
onCleanup(() => {
listener.then((cb) => cb())
void listener.then((cb) => cb())
timers.forEach(clearTimeout)
})
})

View File

@@ -186,5 +186,5 @@ export async function createMenu(trigger: (id: string) => void) {
}),
],
})
menu.setAsAppMenu()
void menu.setAsAppMenu()
}

View File

@@ -17,7 +17,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
const applyZoom = (next: number) => {
setWebviewZoom(next)
invoke("plugin:webview|set_webview_zoom", {
void invoke("plugin:webview|set_webview_zoom", {
value: next,
})
}

View File

@@ -37,4 +37,4 @@ async function test() {
await Share.remove({ id: shareInfo.id, secret: shareInfo.secret })
}
test()
void test()

View File

@@ -1,4 +1,4 @@
import { describe, expect, test, afterAll } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Share } from "../../src/core/share"
import { Storage } from "../../src/core/storage"
import { Identifier } from "@opencode-ai/shared/util/identifier"

View File

@@ -12,21 +12,8 @@ type Env = {
WEB_DOMAIN: string
}
async function getFeishuTenantToken(): Promise<string> {
const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: Resource.FEISHU_APP_ID.value,
app_secret: Resource.FEISHU_APP_SECRET.value,
}),
})
const data = (await response.json()) as { tenant_access_token?: string }
if (!data.tenant_access_token) throw new Error("Failed to get Feishu tenant token")
return data.tenant_access_token
}
export class SyncServer extends DurableObject<Env> {
// oxlint-disable-next-line no-useless-constructor
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
}
@@ -49,9 +36,9 @@ export class SyncServer extends DurableObject<Env> {
})
}
async webSocketMessage(ws, message) {}
async webSocketMessage(_ws, _message) {}
async webSocketClose(ws, code, reason, wasClean) {
async webSocketClose(ws, code, _reason, _wasClean) {
ws.close(code, "Durable Object is closing WebSocket")
}
@@ -195,7 +182,7 @@ export default new Hono<{ Bindings: Env }>()
let info
const messages: Record<string, any> = {}
data.forEach((d) => {
const [root, type, ...splits] = d.key.split("/")
const [root, type] = d.key.split("/")
if (root !== "session") return
if (type === "info") {
info = d.content

View File

@@ -0,0 +1,31 @@
{
"name": ".opencode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "*"
}
},
"node_modules/@opencode-ai/plugin": {
"version": "1.2.6",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.2.6",
"zod": "4.1.8"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.2.6",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -113,13 +113,14 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@openrouter/ai-sdk-provider": "2.5.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@parcel/watcher": "2.5.1",

View File

@@ -211,9 +211,7 @@ for (const item of targets) {
execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
files: {
...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}),
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: [
"./src/index.ts",
parserWorker,

View File

@@ -64,7 +64,7 @@ function findBinary() {
return { binaryPath, binaryName }
} catch (error) {
throw new Error(`Could not find package ${packageName}: ${error.message}`)
throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error })
}
}
@@ -85,18 +85,6 @@ function prepareBinDirectory(binaryName) {
return { binDir, targetPath }
}
function symlinkBinary(sourcePath, binaryName) {
const { targetPath } = prepareBinDirectory(binaryName)
fs.symlinkSync(sourcePath, targetPath)
console.log(`opencode binary symlinked: ${targetPath} -> ${sourcePath}`)
// Verify the file exists after operation
if (!fs.existsSync(targetPath)) {
throw new Error(`Failed to symlink binary to ${targetPath}`)
}
}
async function main() {
try {
if (os.platform() === "win32") {
@@ -124,7 +112,7 @@ async function main() {
}
try {
main()
void main()
} catch (error) {
console.error("Postinstall script error:", error.message)
process.exit(0)

View File

@@ -107,7 +107,7 @@ if (!Script.preview) {
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
} catch (e) {
} catch {
continue
}
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { z } from "zod"
import { Config } from "../src/config/config"
import { Config } from "../src/config"
import { TuiConfig } from "../src/config/tui"
function generate(schema: z.ZodType) {

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env bun
/**
* Unwrap a TypeScript `export namespace` into flat exports + barrel.
*
* Usage:
* bun script/unwrap-namespace.ts src/bus/index.ts
* bun script/unwrap-namespace.ts src/bus/index.ts --dry-run
* bun script/unwrap-namespace.ts src/pty/index.ts --name service # avoid collision with pty.ts
*
* What it does:
* 1. Reads the file and finds the `export namespace Foo { ... }` block
* (uses ast-grep for accurate AST-based boundary detection)
* 2. Removes the namespace wrapper and dedents the body
* 3. Fixes self-references (e.g. Config.PermissionAction → PermissionAction)
* 4. If the file is index.ts, renames it to <lowercase-name>.ts
* 5. Creates/updates index.ts with `export * as Foo from "./<file>"`
* 6. Rewrites import paths across src/, test/, and script/
* 7. Fixes sibling imports within the same directory
*
* Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`)
*/
import path from "path"
import fs from "fs"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const nameFlag = args.find((a, i) => args[i - 1] === "--name")
const filePath = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--name")
if (!filePath) {
console.error("Usage: bun script/unwrap-namespace.ts <file> [--dry-run] [--name <impl-name>]")
process.exit(1)
}
const absPath = path.resolve(filePath)
if (!fs.existsSync(absPath)) {
console.error(`File not found: ${absPath}`)
process.exit(1)
}
const src = fs.readFileSync(absPath, "utf-8")
const lines = src.split("\n")
// Use ast-grep to find the namespace boundaries accurately.
// This avoids false matches from braces in strings, templates, comments, etc.
const astResult = Bun.spawnSync(
["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath],
{ stdout: "pipe", stderr: "pipe" },
)
if (astResult.exitCode !== 0) {
console.error("ast-grep failed:", astResult.stderr.toString())
process.exit(1)
}
const matches = JSON.parse(astResult.stdout.toString()) as Array<{
text: string
range: { start: { line: number; column: number }; end: { line: number; column: number } }
metaVariables: { single: Record<string, { text: string }>; multi: Record<string, Array<{ text: string }>> }
}>
if (matches.length === 0) {
console.error("No `export namespace Foo { ... }` found in file")
process.exit(1)
}
if (matches.length > 1) {
console.error(`Found ${matches.length} namespaces — this script handles one at a time`)
console.error("Namespaces found:")
for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`)
process.exit(1)
}
const match = matches[0]
const nsName = match.metaVariables.single.NAME.text
const nsLine = match.range.start.line // 0-indexed
const closeLine = match.range.end.line // 0-indexed, the line with closing `}`
console.log(`Found: export namespace ${nsName} { ... }`)
console.log(` Lines ${nsLine + 1}${closeLine + 1} (${closeLine - nsLine + 1} lines)`)
// Build the new file content:
// 1. Everything before the namespace declaration (imports, etc.)
// 2. The namespace body, dedented by one level (2 spaces)
// 3. Everything after the closing brace (rare, but possible)
const before = lines.slice(0, nsLine)
const body = lines.slice(nsLine + 1, closeLine)
const after = lines.slice(closeLine + 1)
// Dedent: remove exactly 2 leading spaces from each line
const dedented = body.map((line) => {
if (line === "") return ""
if (line.startsWith(" ")) return line.slice(2)
return line
})
let newContent = [...before, ...dedented, ...after].join("\n")
// --- Fix self-references ---
// After unwrapping, references like `Config.PermissionAction` inside the same file
// need to become just `PermissionAction`. Only fix code positions, not strings.
const exportedNames = new Set<string>()
const exportRegex = /export\s+(?:const|function|class|interface|type|enum|abstract\s+class)\s+(\w+)/g
for (const line of dedented) {
for (const m of line.matchAll(exportRegex)) exportedNames.add(m[1])
}
const reExportRegex = /export\s*\{\s*([^}]+)\}/g
for (const line of dedented) {
for (const m of line.matchAll(reExportRegex)) {
for (const name of m[1].split(",")) {
const trimmed = name
.trim()
.split(/\s+as\s+/)
.pop()!
.trim()
if (trimmed) exportedNames.add(trimmed)
}
}
}
let selfRefCount = 0
if (exportedNames.size > 0) {
const fixedLines = newContent.split("\n").map((line) => {
// Split line into string-literal and code segments to avoid replacing inside strings
const segments: Array<{ text: string; isString: boolean }> = []
let i = 0
let current = ""
let inString: string | null = null
while (i < line.length) {
const ch = line[i]
if (inString) {
current += ch
if (ch === "\\" && i + 1 < line.length) {
current += line[i + 1]
i += 2
continue
}
if (ch === inString) {
segments.push({ text: current, isString: true })
current = ""
inString = null
}
i++
continue
}
if (ch === '"' || ch === "'" || ch === "`") {
if (current) segments.push({ text: current, isString: false })
current = ch
inString = ch
i++
continue
}
if (ch === "/" && i + 1 < line.length && line[i + 1] === "/") {
current += line.slice(i)
segments.push({ text: current, isString: true })
current = ""
i = line.length
continue
}
current += ch
i++
}
if (current) segments.push({ text: current, isString: !!inString })
return segments
.map((seg) => {
if (seg.isString) return seg.text
let result = seg.text
for (const name of exportedNames) {
const pattern = `${nsName}.${name}`
while (result.includes(pattern)) {
const idx = result.indexOf(pattern)
const charBefore = idx > 0 ? result[idx - 1] : " "
const charAfter = idx + pattern.length < result.length ? result[idx + pattern.length] : " "
if (/\w/.test(charBefore) || /\w/.test(charAfter)) break
result = result.slice(0, idx) + name + result.slice(idx + pattern.length)
selfRefCount++
}
}
return result
})
.join("")
})
newContent = fixedLines.join("\n")
}
// Figure out file naming
const dir = path.dirname(absPath)
const basename = path.basename(absPath, ".ts")
const isIndex = basename === "index"
const implName = nameFlag ?? (isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename)
const implFile = path.join(dir, `${implName}.ts`)
const indexFile = path.join(dir, "index.ts")
const barrelLine = `export * as ${nsName} from "./${implName}"\n`
console.log("")
if (isIndex) {
console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`)
} else {
console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`)
}
if (selfRefCount > 0) console.log(`Fixed ${selfRefCount} self-reference(s) (${nsName}.X → X)`)
console.log("")
if (dryRun) {
console.log("--- DRY RUN ---")
console.log("")
console.log(`=== ${implName}.ts (first 30 lines) ===`)
newContent
.split("\n")
.slice(0, 30)
.forEach((l, i) => console.log(` ${i + 1}: ${l}`))
console.log(" ...")
console.log("")
console.log(`=== index.ts ===`)
console.log(` ${barrelLine.trim()}`)
console.log("")
if (!isIndex) {
const relDir = path.relative(path.resolve("src"), dir)
console.log(`=== Import rewrites (would apply) ===`)
console.log(` ${relDir}/${basename}" → ${relDir}" across src/, test/, script/`)
} else {
console.log("No import rewrites needed (was index.ts)")
}
} else {
if (isIndex) {
fs.writeFileSync(implFile, newContent)
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`)
console.log(`Wrote index.ts (barrel)`)
} else {
fs.writeFileSync(absPath, newContent)
if (fs.existsSync(indexFile)) {
const existing = fs.readFileSync(indexFile, "utf-8")
if (!existing.includes(`export * as ${nsName}`)) {
fs.appendFileSync(indexFile, barrelLine)
console.log(`Appended to existing index.ts`)
} else {
console.log(`index.ts already has ${nsName} export`)
}
} else {
fs.writeFileSync(indexFile, barrelLine)
console.log(`Wrote index.ts (barrel)`)
}
console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`)
}
// --- Rewrite import paths across src/, test/, script/ ---
const relDir = path.relative(path.resolve("src"), dir)
if (!isIndex) {
const oldTail = `${relDir}/${basename}`
const searchDirs = ["src", "test", "script"].filter((d) => fs.existsSync(d))
const rgResult = Bun.spawnSync(["rg", "-l", `from.*${oldTail}"`, ...searchDirs], {
stdout: "pipe",
stderr: "pipe",
})
const filesToRewrite = rgResult.stdout
.toString()
.trim()
.split("\n")
.filter((f) => f.length > 0)
if (filesToRewrite.length > 0) {
console.log(`\nRewriting imports in ${filesToRewrite.length} file(s)...`)
for (const file of filesToRewrite) {
const content = fs.readFileSync(file, "utf-8")
fs.writeFileSync(file, content.replaceAll(`${oldTail}"`, `${relDir}"`))
}
console.log(` Done: ${oldTail}" → ${relDir}"`)
} else {
console.log("\nNo import rewrites needed")
}
} else {
console.log("\nNo import rewrites needed (was index.ts)")
}
// --- Fix sibling imports within the same directory ---
const siblingFiles = fs.readdirSync(dir).filter((f) => {
if (!f.endsWith(".ts")) return false
if (f === "index.ts" || f === `${implName}.ts`) return false
return true
})
let siblingFixCount = 0
for (const sibFile of siblingFiles) {
const sibPath = path.join(dir, sibFile)
const content = fs.readFileSync(sibPath, "utf-8")
const pattern = new RegExp(`from\\s+["']\\./${basename}["']`, "g")
if (pattern.test(content)) {
fs.writeFileSync(sibPath, content.replace(pattern, `from "."`))
siblingFixCount++
}
}
if (siblingFixCount > 0) {
console.log(`Fixed ${siblingFixCount} sibling import(s) in ${path.basename(dir)}/ (./${basename} → .)`)
}
}
console.log("")
console.log("=== Verify ===")
console.log("")
console.log("bunx --bun tsgo --noEmit # typecheck")
console.log("bun run test # run tests")

View File

@@ -121,18 +121,46 @@ Why `question` first:
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
### 4. Run in parallel before replacing
### 4. Bridge into Hono behind a feature flag
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
- handler ergonomics
- OpenAPI output
- auth and middleware integration
- test ergonomics
- one process, one port — no separate server
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
- Effect middleware handles auth and instance lookup independently from Hono middleware
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
### 5. Migrate JSON route groups gradually
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
```ts
// in instance/index.ts
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
}
```
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
### 5. Observability
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
This gives:
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
### 6. Migrate JSON route groups gradually
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
## Schema rule for HttpApi work
@@ -157,6 +185,14 @@ Ordering for a route-group migration:
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
4. switch existing Zod boundary validators to derived `.zod`
5. define the `HttpApi` contract from the canonical Effect schemas
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
Temporary exception:
@@ -196,8 +232,9 @@ Use the same sequence for each route group.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
7. Add one end-to-end test and one OpenAPI-focused test.
8. Compare ergonomics before migrating the next endpoint.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
Rule of thumb:
@@ -218,17 +255,15 @@ Placement rule:
Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `test/server/question-httpapi.test.ts`
- `test/server/question-httpapi-openapi.test.ts`
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice
- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer
- `question-httpapi.test.ts` proves the route works end-to-end against the real service
- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
## Example migration shape
@@ -248,11 +283,12 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Mounting
### 3. Standalone server
- mount under an experimental prefix such as `/experimental/httpapi`
- keep existing Hono routes unchanged
- expose separate OpenAPI output for the experimental slice first
- the Effect HTTP server is self-contained in `httpapi/server.ts`
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
- each route group exposes its own OpenAPI doc endpoint
### 4. Verification
@@ -263,89 +299,75 @@ Each route-group spike should follow the same shape.
## Boundary composition
The first slices should keep the existing outer server composition and only replace the route contract and handler layer.
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
### Auth
- keep `AuthMiddleware` at the outer Hono app level
- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices
- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler
Practical rule:
- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
### Instance and workspace lookup
- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context
- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler
- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them
Practical rule:
- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided
- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
### Error mapping
- keep domain and service errors typed in the service layer
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior
Practical rule:
- request decoding failures should remain transport-level `400`s
- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
- unexpected defects can still fall through to the outer error middleware while the slice is experimental
For the current parallel slices, this means:
- auth still composes outside `HttpApi`
- instance selection still composes outside `HttpApi`
- success payloads should be schema-defined from canonical Effect schemas
- known route errors should be modeled at the endpoint boundary incrementally instead of all at once
## Exit criteria for the spike
The first slice is successful if:
- the endpoints run in parallel with the current Hono routes
- the standalone Effect server starts and serves the endpoints independently of the Hono server
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
- OpenAPI is generated from the `HttpApi` contract
- the tests are straightforward enough that the next slice feels mechanical
## Learnings from the question slice
## Learnings
The first parallel `question` spike gave us a concrete pattern to reuse.
### Schema
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged.
- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix.
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
### Integration
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
## Route inventory
Status legend:
- `done` - parallel `HttpApi` slice exists
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
- `done` - Effect HttpApi slice exists but not yet bridged
- `next` - good near-term candidate
- `later` - possible, but not first wave
- `defer` - not a good early `HttpApi` target
Current instance route inventory:
- `question` - `done`
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
- `permission` - `done`
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `next`
best next endpoint: `GET /provider/auth`
later endpoint: `GET /provider`
defer first-wave OAuth mutations
- `question` - `bridged`
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config`
@@ -385,7 +407,13 @@ Recommended near-term sequence after the first spike:
- [x] keep the underlying service calls identical to the current handlers
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb

View File

@@ -0,0 +1,444 @@
# Namespace → flat export migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts
// BEFORE — points at the file directly
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
```
## Why this matters right now
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
```
src/index.ts (entry point)
└── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines)
│ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers
│ ├── google-auth-library
│ ├── gitlab-ai-provider, venice-ai-sdk-provider
│ └── fuzzysort, remeda, etc.
├── { Config } from config/config.ts (1663 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code
that needs maybe 200 bytes total. This inflates the binary, increases startup
memory, and slows down initial module evaluation.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
### 2. Barrel file uses `export * as` (not `export namespace`)
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace` → `export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` →
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
```
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation
The transformation is scripted. From `packages/opencode`:
```bash
bun script/unwrap-namespace.ts <file> [--dry-run]
```
The script uses ast-grep for accurate AST-based namespace boundary detection
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}`
2. Dedents the body by one indent level (2 spaces)
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new
`index.ts` barrel
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts`
5. Prints the exact commands to find and rewrite import paths
### Walkthrough: converting a module
Using `Provider` as an example:
```bash
# 1. Preview what will change
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run
# 2. Apply the transformation
bun script/unwrap-namespace.ts src/provider/provider.ts
# 3. Rewrite import paths (script prints the exact command)
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g'
# 4. Verify
bun typecheck
bun run test
```
**What changes on disk:**
```
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.)
stays identical.
### Two cases the script handles
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`)
- Rewrites the file in place (unwrap + dedent)
- Creates `provider/index.ts` as the barrel
- Import paths change: `"../provider/provider"` → `"../provider"`
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
- Renames `index.ts` → `bus.ts` (kebab-case of namespace name)
- Creates new `index.ts` as the barrel
- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles?
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
organization** — smaller files are easier to read and review. But it's not
required for tree-shaking. The `export * as` conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding `"sideEffects": []` to package.json handles the common cases.
## Scope
| Metric | Count |
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code
Going forward:
- **No new `export namespace`**. Use a file with flat named exports and
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The `export * as` barrel makes everything
individually shakeable regardless of file structure.
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
bundler handles that.

View File

@@ -0,0 +1,454 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
AccountTransportError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export {
AccountID,
type AccountError,
AccountRepoError,
AccountServiceError,
AccountTransportError,
AccessToken,
RefreshToken,
DeviceCode,
UserCode,
Info,
Org,
OrgID,
Login,
PollSuccess,
PollPending,
PollSlow,
PollExpired,
PollDenied,
PollError,
PollResult,
} from "./schema"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const eagerRefreshThreshold = Duration.minutes(5)
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message)))
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
return cause
}
if (HttpClientError.isHttpClientError(cause)) {
switch (cause.reason._tag) {
case "TransportError": {
return AccountTransportError.fromHttpClientError(cause.reason)
}
default: {
return new AccountServiceError({ message, cause })
}
}
}
return new AccountServiceError({ message, cause })
}
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (accountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) {
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
}
const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(account.token_expiry, now)) {
return account.access_token
}
return yield* refreshToken(account)
}),
})
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(row.token_expiry, now)) {
return row.access_token
}
return yield* Cache.get(refreshTokenCache, row.id)
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
const activeAccount = yield* repo.active()
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
const account = activeAccount.value
if (!account.active_org_id) return Option.none<ActiveOrg>()
const accountOrgs = yield* orgs(account.id)
const org = accountOrgs.find((item) => item.id === account.active_org_id)
if (!org) return Option.none<ActiveOrg>()
return Option.some({ account, org })
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
accounts,
(account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
{ concurrency: 3 },
)
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const normalizedServer = normalizeServerUrl(server)
const response = yield* executeEffectOk(
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${normalizedServer}${parsed.verification_uri_complete}`,
server: normalizedServer,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
activeOrg,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))

View File

@@ -1,37 +1,4 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
import {
type AccountError,
AccessToken,
AccountID,
DeviceCode,
Info,
RefreshToken,
AccountServiceError,
AccountTransportError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * as Account from "./account"
export {
AccountID,
type AccountError,
@@ -52,405 +19,6 @@ export {
PollExpired,
PollDenied,
PollError,
PollResult,
type PollResult,
} from "./schema"
export type AccountOrgs = {
account: Info
orgs: readonly Org[]
}
export type ActiveOrg = {
account: Info
org: Org
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const eagerRefreshThreshold = Duration.minutes(5)
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
effect.pipe(Effect.mapError((cause) => accountErrorFromCause(cause, message)))
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
return cause
}
if (HttpClientError.isHttpClientError(cause)) {
switch (cause.reason._tag) {
case "TransportError": {
return AccountTransportError.fromHttpClientError(cause.reason)
}
default: {
return new AccountServiceError({ message, cause })
}
}
}
return new AccountServiceError({ message, cause })
}
export namespace Account {
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
readonly activeOrg: () => Effect.Effect<Option.Option<ActiveOrg>, AccountError>
readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (accountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) {
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
}
const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(account.token_expiry, now)) {
return account.access_token
}
return yield* refreshToken(account)
}),
})
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (isTokenFresh(row.token_expiry, now)) {
return row.access_token
}
return yield* Cache.get(refreshTokenCache, row.id)
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const activeOrg = Effect.fn("Account.activeOrg")(function* () {
const activeAccount = yield* repo.active()
if (Option.isNone(activeAccount)) return Option.none<ActiveOrg>()
const account = activeAccount.value
if (!account.active_org_id) return Option.none<ActiveOrg>()
const accountOrgs = yield* orgs(account.id)
const org = accountOrgs.find((item) => item.id === account.active_org_id)
if (!org) return Option.none<ActiveOrg>()
return Option.some({ account, org })
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
return yield* Effect.forEach(
accounts,
(account) =>
orgs(account.id).pipe(
Effect.catch(() => Effect.succeed([] as readonly Org[])),
Effect.map((orgs) => ({ account, orgs })),
),
{ concurrency: 3 },
)
})
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const normalizedServer = normalizeServerUrl(server)
const response = yield* executeEffectOk(
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${normalizedServer}${parsed.verification_uri_complete}`,
server: normalizedServer,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
activeOrg,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}
export type { AccountOrgs, ActiveOrg } from "./account"

View File

@@ -31,19 +31,19 @@ import {
type Usage,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { Log } from "../util"
import { pathToFileURL } from "url"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { Filesystem } from "../util"
import { Hash } from "@opencode-ai/shared/util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config/config"
import { Config } from "@/config"
import { Todo } from "@/session/todo"
import { z } from "zod"
import { LoadAPIKeyError } from "ai"
@@ -242,7 +242,7 @@ export namespace ACP {
const newContent = getNewContent(content, diff)
if (newContent) {
this.connection.writeTextFile({
void this.connection.writeTextFile({
sessionId: session.id,
path: filepath,
content: newContent,
@@ -1253,7 +1253,7 @@ export namespace ACP {
)
setTimeout(() => {
this.connection.sessionUpdate({
void this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
@@ -1566,7 +1566,6 @@ export namespace ACP {
case "context7_get_library_docs":
return "search"
case "list":
case "read":
return "read"
@@ -1587,8 +1586,6 @@ export namespace ACP {
return input["path"] ? [{ path: input["path"] }] : []
case "bash":
return []
case "list":
return input["path"] ? [{ path: input["path"] }] : []
default:
return []
}

View File

@@ -1,6 +1,6 @@
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types"
import { Log } from "@/util/log"
import { Log } from "@/util"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
const log = Log.create({ service: "acp-session-manager" })

View File

@@ -1,6 +1,6 @@
import { Config } from "../config/config"
import { Config } from "../config"
import z from "zod"
import { Provider } from "../provider/provider"
import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance"
@@ -20,7 +20,7 @@ import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { InstanceState } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
@@ -35,7 +35,7 @@ export namespace Agent {
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset,
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
@@ -80,7 +80,7 @@ export namespace Agent {
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) {
Effect.fn("Agent.state")(function* (_ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
@@ -368,7 +368,7 @@ export namespace Agent {
)),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
content: `Create an agent configuration based on this request: "${input.description}".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,

View File

@@ -0,0 +1,89 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

View File

@@ -1,91 +1,2 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new Auth.AuthError({ message, cause })
export namespace Auth {
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
const _Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })
export const Info = Object.assign(_Info, { zod: zod(_Info) })
export type Info = Schema.Schema.Type<typeof _Info>
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
readonly remove: (key: string) => Effect.Effect<void, AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
}
export * as Auth from "./auth"
export { OAUTH_DUMMY_KEY } from "./auth"

View File

@@ -25,7 +25,7 @@ export namespace BusEvent {
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
ref: `Event.${def.type}`,
})
})
.toArray()

View File

@@ -0,0 +1,191 @@
import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect"
import { EffectBridge } from "@/effect"
import { Log } from "../util"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect"
import { makeRuntime } from "@/effect/run-service"
const log = Log.create({ service: "bus" })
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
z.object({
directory: z.string(),
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
type: D["type"]
properties: z.infer<D["properties"]>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: z.output<D["properties"]>,
) => Effect.Effect<void>
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
readonly subscribeAll: () => Stream.Stream<Payload>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
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,
})
})
}
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
log.info("subscribing", { type: def.type })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
}
function subscribeAll(): Stream.Stream<Payload> {
log.info("subscribing", { type: "*" })
return Stream.unwrap(
Effect.gen(function* () {
const s = yield* InstanceState.get(state)
return Stream.fromPubSub(s.wildcard)
}),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
}
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return runPromise((svc) => svc.publish(def, properties))
}
export function subscribe<D extends BusEvent.Definition>(
def: D,
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}

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