summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.opencode/agent/translator.md1
-rw-r--r--.opencode/opencode.jsonc6
-rw-r--r--bun.lock118
-rw-r--r--infra/console.ts1
-rw-r--r--nix/hashes.json8
-rw-r--r--nix/opencode.nix37
-rw-r--r--package.json6
-rw-r--r--packages/app/package.json2
-rw-r--r--packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2bin0 -> 1060580 bytes
-rw-r--r--packages/app/src/components/settings-general.tsx27
-rw-r--r--packages/app/src/components/terminal.tsx6
-rw-r--r--packages/app/src/context/global-sync.tsx3
-rw-r--r--packages/app/src/context/global-sync/child-store.ts1
-rw-r--r--packages/app/src/context/global-sync/types.ts1
-rw-r--r--packages/app/src/context/settings.tsx18
-rw-r--r--packages/app/src/i18n/ar.ts4
-rw-r--r--packages/app/src/i18n/br.ts4
-rw-r--r--packages/app/src/i18n/bs.ts4
-rw-r--r--packages/app/src/i18n/da.ts4
-rw-r--r--packages/app/src/i18n/de.ts4
-rw-r--r--packages/app/src/i18n/en.ts4
-rw-r--r--packages/app/src/i18n/es.ts4
-rw-r--r--packages/app/src/i18n/fr.ts4
-rw-r--r--packages/app/src/i18n/ja.ts4
-rw-r--r--packages/app/src/i18n/ko.ts4
-rw-r--r--packages/app/src/i18n/no.ts4
-rw-r--r--packages/app/src/i18n/pl.ts4
-rw-r--r--packages/app/src/i18n/ru.ts4
-rw-r--r--packages/app/src/i18n/th.ts4
-rw-r--r--packages/app/src/i18n/tr.ts4
-rw-r--r--packages/app/src/i18n/zh.ts4
-rw-r--r--packages/app/src/i18n/zht.ts4
-rw-r--r--packages/app/src/index.css7
-rw-r--r--packages/app/src/pages/layout.tsx362
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx12
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx7
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx5
-rw-r--r--packages/app/src/utils/persist.ts2
-rw-r--r--packages/console/app/package.json2
-rw-r--r--packages/console/app/src/i18n/ar.ts9
-rw-r--r--packages/console/app/src/i18n/br.ts9
-rw-r--r--packages/console/app/src/i18n/da.ts9
-rw-r--r--packages/console/app/src/i18n/de.ts9
-rw-r--r--packages/console/app/src/i18n/en.ts9
-rw-r--r--packages/console/app/src/i18n/es.ts9
-rw-r--r--packages/console/app/src/i18n/fr.ts9
-rw-r--r--packages/console/app/src/i18n/it.ts9
-rw-r--r--packages/console/app/src/i18n/ja.ts9
-rw-r--r--packages/console/app/src/i18n/ko.ts9
-rw-r--r--packages/console/app/src/i18n/no.ts9
-rw-r--r--packages/console/app/src/i18n/pl.ts9
-rw-r--r--packages/console/app/src/i18n/ru.ts9
-rw-r--r--packages/console/app/src/i18n/th.ts9
-rw-r--r--packages/console/app/src/i18n/tr.ts9
-rw-r--r--packages/console/app/src/i18n/zh.ts9
-rw-r--r--packages/console/app/src/i18n/zht.ts9
-rw-r--r--packages/console/app/src/routes/api/enterprise.ts71
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts7
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/index.tsx2
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css61
-rw-r--r--packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx71
-rw-r--r--packages/console/app/src/routes/zen/util/handler.ts27
-rw-r--r--packages/console/app/src/routes/zen/util/modelTpmLimiter.ts51
-rw-r--r--packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql6
-rw-r--r--packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json2567
-rw-r--r--packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql6
-rw-r--r--packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json2619
-rw-r--r--packages/console/core/package.json2
-rw-r--r--packages/console/core/script/create-coupon.ts24
-rw-r--r--packages/console/core/src/billing.ts54
-rw-r--r--packages/console/core/src/lite.ts8
-rw-r--r--packages/console/core/src/model.ts15
-rw-r--r--packages/console/core/src/schema/billing.sql.ts24
-rw-r--r--packages/console/core/src/schema/ip.sql.ts10
-rw-r--r--packages/console/core/sst-env.d.ts4
-rw-r--r--packages/console/function/package.json2
-rw-r--r--packages/console/function/sst-env.d.ts4
-rw-r--r--packages/console/mail/package.json2
-rw-r--r--packages/console/resource/sst-env.d.ts4
-rw-r--r--packages/desktop-electron/package.json5
-rw-r--r--packages/desktop-electron/src/main/index.ts36
-rw-r--r--packages/desktop-electron/src/main/migrate.ts8
-rw-r--r--packages/desktop-electron/src/main/server.ts12
-rw-r--r--packages/desktop-electron/src/main/store.ts6
-rw-r--r--packages/desktop/package.json2
-rw-r--r--packages/enterprise/package.json2
-rw-r--r--packages/enterprise/sst-env.d.ts4
-rw-r--r--packages/extensions/zed/extension.toml12
-rw-r--r--packages/function/package.json2
-rw-r--r--packages/function/sst-env.d.ts4
-rw-r--r--packages/opencode/package.json17
-rwxr-xr-xpackages/opencode/script/build.ts10
-rwxr-xr-xpackages/opencode/script/publish.ts31
-rw-r--r--packages/opencode/specs/effect/facades.md81
-rw-r--r--packages/opencode/specs/effect/http-api.md131
-rw-r--r--packages/opencode/specs/effect/instance-context.md20
-rw-r--r--packages/opencode/specs/effect/loose-ends.md14
-rw-r--r--packages/opencode/specs/effect/migration.md74
-rw-r--r--packages/opencode/specs/effect/routes.md34
-rw-r--r--packages/opencode/specs/effect/schema.md282
-rw-r--r--packages/opencode/specs/effect/server-package.md36
-rw-r--r--packages/opencode/specs/effect/tools.md8
-rw-r--r--packages/opencode/src/cli/cmd/generate.ts14
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx26
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx1
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx9
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx12
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx81
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/logo.tsx49
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx124
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts49
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts6
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui-schema.ts2
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui.ts38
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/kv.tsx32
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/local.tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/project.tsx19
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/route.tsx21
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sdk.tsx28
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/sync.tsx32
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/theme.tsx10
-rw-r--r--packages/opencode/src/cli/cmd/tui/layer.ts2
-rw-r--r--packages/opencode/src/cli/cmd/tui/plugin/api.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/home.tsx5
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx29
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx30
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx49
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx25
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts9
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/signal.ts36
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/terminal.ts39
-rw-r--r--packages/opencode/src/cli/cmd/tui/worker.ts3
-rw-r--r--packages/opencode/src/config/agent.ts177
-rw-r--r--packages/opencode/src/config/command.ts22
-rw-r--r--packages/opencode/src/config/config.ts820
-rw-r--r--packages/opencode/src/config/console-state.ts21
-rw-r--r--packages/opencode/src/config/formatter.ts22
-rw-r--r--packages/opencode/src/config/keybinds.ts287
-rw-r--r--packages/opencode/src/config/layout.ts10
-rw-r--r--packages/opencode/src/config/lsp.ts62
-rw-r--r--packages/opencode/src/config/mcp.ts118
-rw-r--r--packages/opencode/src/config/model-id.ts13
-rw-r--r--packages/opencode/src/config/paths.ts46
-rw-r--r--packages/opencode/src/config/permission.ts96
-rw-r--r--packages/opencode/src/config/plugin.ts14
-rw-r--r--packages/opencode/src/config/provider.ts220
-rw-r--r--packages/opencode/src/config/server.ts20
-rw-r--r--packages/opencode/src/config/skills.ts21
-rw-r--r--packages/opencode/src/control-plane/types.ts2
-rw-r--r--packages/opencode/src/control-plane/workspace-context.ts4
-rw-r--r--packages/opencode/src/control-plane/workspace.ts219
-rw-r--r--packages/opencode/src/effect/app-runtime.ts7
-rw-r--r--packages/opencode/src/effect/bootstrap-runtime.ts2
-rw-r--r--packages/opencode/src/effect/logger.ts8
-rw-r--r--packages/opencode/src/effect/memo-map.ts3
-rw-r--r--packages/opencode/src/effect/observability.ts67
-rw-r--r--packages/opencode/src/effect/run-service.ts3
-rw-r--r--packages/opencode/src/effect/runtime.ts (renamed from packages/opencode/src/cli/effect/runtime.ts)5
-rw-r--r--packages/opencode/src/file/index.ts48
-rw-r--r--packages/opencode/src/file/ripgrep.ts650
-rw-r--r--packages/opencode/src/file/ripgrep.worker.ts103
-rw-r--r--packages/opencode/src/file/time.ts113
-rw-r--r--packages/opencode/src/flag/flag.ts3
-rw-r--r--packages/opencode/src/format/formatter.ts40
-rw-r--r--packages/opencode/src/format/index.ts6
-rw-r--r--packages/opencode/src/index.ts5
-rw-r--r--packages/opencode/src/lsp/client.ts39
-rw-r--r--packages/opencode/src/lsp/lsp.ts25
-rw-r--r--packages/opencode/src/lsp/server.ts84
-rw-r--r--packages/opencode/src/mcp/index.ts3
-rw-r--r--packages/opencode/src/npm/index.ts423
-rw-r--r--packages/opencode/src/plugin/github-copilot/copilot.ts8
-rw-r--r--packages/opencode/src/plugin/github-copilot/models.ts9
-rw-r--r--packages/opencode/src/plugin/loader.ts42
-rw-r--r--packages/opencode/src/plugin/shared.ts2
-rw-r--r--packages/opencode/src/project/instance.ts2
-rw-r--r--packages/opencode/src/project/project.ts76
-rw-r--r--packages/opencode/src/project/vcs.ts15
-rw-r--r--packages/opencode/src/provider/provider.ts32
-rw-r--r--packages/opencode/src/provider/transform.ts13
-rw-r--r--packages/opencode/src/server/instance/trace.ts33
-rw-r--r--packages/opencode/src/server/proxy.ts7
-rw-r--r--packages/opencode/src/server/routes/control/index.ts (renamed from packages/opencode/src/server/control/index.ts)4
-rw-r--r--packages/opencode/src/server/routes/control/workspace.ts (renamed from packages/opencode/src/server/instance/workspace.ts)10
-rw-r--r--packages/opencode/src/server/routes/global.ts (renamed from packages/opencode/src/server/instance/global.ts)0
-rw-r--r--packages/opencode/src/server/routes/instance/config.ts (renamed from packages/opencode/src/server/instance/config.ts)21
-rw-r--r--packages/opencode/src/server/routes/instance/event.ts (renamed from packages/opencode/src/server/instance/event.ts)2
-rw-r--r--packages/opencode/src/server/routes/instance/experimental.ts (renamed from packages/opencode/src/server/instance/experimental.ts)200
-rw-r--r--packages/opencode/src/server/routes/instance/file.ts (renamed from packages/opencode/src/server/instance/file.ts)98
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/config.ts (renamed from packages/opencode/src/server/instance/httpapi/config.ts)0
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/permission.ts (renamed from packages/opencode/src/server/instance/httpapi/permission.ts)0
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/project.ts62
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/provider.ts (renamed from packages/opencode/src/server/instance/httpapi/provider.ts)0
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/question.ts (renamed from packages/opencode/src/server/instance/httpapi/question.ts)0
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts (renamed from packages/opencode/src/server/instance/httpapi/server.ts)5
-rw-r--r--packages/opencode/src/server/routes/instance/index.ts (renamed from packages/opencode/src/server/instance/index.ts)120
-rw-r--r--packages/opencode/src/server/routes/instance/mcp.ts (renamed from packages/opencode/src/server/instance/mcp.ts)90
-rw-r--r--packages/opencode/src/server/routes/instance/middleware.ts35
-rw-r--r--packages/opencode/src/server/routes/instance/permission.ts (renamed from packages/opencode/src/server/instance/permission.ts)41
-rw-r--r--packages/opencode/src/server/routes/instance/project.ts (renamed from packages/opencode/src/server/instance/project.ts)38
-rw-r--r--packages/opencode/src/server/routes/instance/provider.ts (renamed from packages/opencode/src/server/instance/provider.ts)131
-rw-r--r--packages/opencode/src/server/routes/instance/pty.ts (renamed from packages/opencode/src/server/instance/pty.ts)73
-rw-r--r--packages/opencode/src/server/routes/instance/question.ts (renamed from packages/opencode/src/server/instance/question.ts)53
-rw-r--r--packages/opencode/src/server/routes/instance/session.ts (renamed from packages/opencode/src/server/instance/session.ts)435
-rw-r--r--packages/opencode/src/server/routes/instance/sync.ts (renamed from packages/opencode/src/server/instance/sync.ts)28
-rw-r--r--packages/opencode/src/server/routes/instance/trace.ts59
-rw-r--r--packages/opencode/src/server/routes/instance/tui.ts (renamed from packages/opencode/src/server/instance/tui.ts)18
-rw-r--r--packages/opencode/src/server/routes/ui.ts (renamed from packages/opencode/src/server/ui/index.ts)0
-rw-r--r--packages/opencode/src/server/server.ts43
-rw-r--r--packages/opencode/src/server/workspace.ts (renamed from packages/opencode/src/server/instance/middleware.ts)48
-rw-r--r--packages/opencode/src/session/compaction.ts2
-rw-r--r--packages/opencode/src/session/instruction.ts9
-rw-r--r--packages/opencode/src/session/llm.ts16
-rw-r--r--packages/opencode/src/session/message-v2.ts6
-rw-r--r--packages/opencode/src/session/processor.ts4
-rw-r--r--packages/opencode/src/session/prompt.ts4
-rw-r--r--packages/opencode/src/session/session.ts9
-rw-r--r--packages/opencode/src/share/share-next.ts41
-rw-r--r--packages/opencode/src/tool/edit.ts119
-rw-r--r--packages/opencode/src/tool/mcp-exa.ts4
-rw-r--r--packages/opencode/src/tool/read.ts148
-rw-r--r--packages/opencode/src/tool/registry.ts3
-rw-r--r--packages/opencode/src/tool/skill.ts127
-rw-r--r--packages/opencode/src/tool/skill.txt5
-rw-r--r--packages/opencode/src/tool/webfetch.ts6
-rw-r--r--packages/opencode/src/tool/write.ts4
-rw-r--r--packages/opencode/src/util/effect-zod.ts279
-rw-r--r--packages/opencode/src/util/media.ts26
-rw-r--r--packages/opencode/src/util/opencode-process.ts24
-rw-r--r--packages/opencode/src/v2/session-entry-stepper.ts261
-rw-r--r--packages/opencode/src/v2/session-entry.ts211
-rw-r--r--packages/opencode/src/v2/session-event.ts17
-rw-r--r--packages/opencode/src/worktree/index.ts24
-rw-r--r--packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts12
-rw-r--r--packages/opencode/test/cli/tui/sync-provider.test.tsx280
-rw-r--r--packages/opencode/test/config/config.test.ts2
-rw-r--r--packages/opencode/test/config/lsp.test.ts87
-rw-r--r--packages/opencode/test/effect/observability.test.ts46
-rw-r--r--packages/opencode/test/file/time.test.ts422
-rw-r--r--packages/opencode/test/lsp/client.test.ts3
-rw-r--r--packages/opencode/test/plugin/loader-shared.test.ts20
-rw-r--r--packages/opencode/test/preload.ts1
-rw-r--r--packages/opencode/test/provider/provider.test.ts34
-rw-r--r--packages/opencode/test/provider/transform.test.ts2
-rw-r--r--packages/opencode/test/server/session-messages.test.ts13
-rw-r--r--packages/opencode/test/server/trace-attributes.test.ts76
-rw-r--r--packages/opencode/test/session/message-v2.test.ts84
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts12
-rw-r--r--packages/opencode/test/session/session-entry-stepper.test.ts916
-rw-r--r--packages/opencode/test/session/session-entry.test.ts624
-rw-r--r--packages/opencode/test/session/snapshot-tool-race.test.ts12
-rw-r--r--packages/opencode/test/tool/edit.test.ts88
-rw-r--r--packages/opencode/test/tool/read.test.ts16
-rw-r--r--packages/opencode/test/tool/skill.test.ts96
-rw-r--r--packages/opencode/test/tool/write.test.ts13
-rw-r--r--packages/opencode/test/util/effect-zod.test.ts688
-rw-r--r--packages/opencode/test/workspace/workspace-restore.test.ts5
-rw-r--r--packages/plugin/package.json10
-rwxr-xr-xpackages/plugin/script/publish.ts38
-rw-r--r--packages/plugin/src/tui.ts2
-rw-r--r--packages/sdk/js/package.json2
-rwxr-xr-xpackages/sdk/js/script/publish.ts50
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts613
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts393
-rw-r--r--packages/sdk/openapi.json978
-rw-r--r--packages/shared/package.json2
-rw-r--r--packages/shared/src/npm.ts249
-rw-r--r--packages/shared/src/util/effect-flock.ts87
-rw-r--r--packages/shared/sst-env.d.ts10
-rw-r--r--packages/shared/test/npm.test.ts18
-rw-r--r--packages/slack/package.json2
-rw-r--r--packages/ui/package.json2
-rw-r--r--packages/ui/src/components/message-part.tsx4
-rw-r--r--packages/ui/src/components/provider-icons/types.ts1
-rw-r--r--packages/web/package.json2
-rw-r--r--packages/web/src/content/docs/ar/cli.mdx1
-rw-r--r--packages/web/src/content/docs/bs/cli.mdx1
-rw-r--r--packages/web/src/content/docs/cli.mdx30
-rw-r--r--packages/web/src/content/docs/da/cli.mdx1
-rw-r--r--packages/web/src/content/docs/de/cli.mdx1
-rw-r--r--packages/web/src/content/docs/es/cli.mdx1
-rw-r--r--packages/web/src/content/docs/fr/cli.mdx1
-rw-r--r--packages/web/src/content/docs/it/cli.mdx1
-rw-r--r--packages/web/src/content/docs/ja/cli.mdx1
-rw-r--r--packages/web/src/content/docs/ko/cli.mdx1
-rw-r--r--packages/web/src/content/docs/nb/cli.mdx1
-rw-r--r--packages/web/src/content/docs/pl/cli.mdx53
-rw-r--r--packages/web/src/content/docs/providers.mdx95
-rw-r--r--packages/web/src/content/docs/pt-br/cli.mdx1
-rw-r--r--packages/web/src/content/docs/ru/cli.mdx1
-rw-r--r--packages/web/src/content/docs/th/cli.mdx1
-rw-r--r--packages/web/src/content/docs/tr/cli.mdx1
-rw-r--r--packages/web/src/content/docs/zh-cn/cli.mdx1
-rw-r--r--packages/web/src/content/docs/zh-tw/cli.mdx1
-rwxr-xr-xscript/publish.ts82
-rwxr-xr-xscript/version.ts4
-rw-r--r--sdks/vscode/package.json2
-rw-r--r--sst-env.d.ts4
298 files changed, 14342 insertions, 7103 deletions
diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md
index a987d0192..8ac7025f1 100644
--- a/.opencode/agent/translator.md
+++ b/.opencode/agent/translator.md
@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
-OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index 8380f7f71..82ab6d1b3 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
- "provider": {
- "opencode": {
- "options": {},
- },
- },
+ "provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
diff --git a/bun.lock b/bun.lock
index 63232cb29..fc9662796 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,8 +225,9 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
+ "drizzle-orm": "catalog:",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
@@ -248,7 +249,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
- "electron": "40.4.1",
+ "electron": "41.2.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",
@@ -268,7 +269,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +298,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +314,7 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "1.4.7",
+ "version": "1.14.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -322,15 +323,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
- "@ai-sdk/amazon-bedrock": "4.0.94",
- "@ai-sdk/anthropic": "3.0.70",
+ "@ai-sdk/amazon-bedrock": "4.0.96",
+ "@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
- "@ai-sdk/gateway": "3.0.102",
+ "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
- "@ai-sdk/google-vertex": "4.0.111",
+ "@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -365,8 +366,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
- "@opentui/core": "0.1.99",
- "@opentui/solid": "0.1.99",
+ "@opentui/core": "0.1.101",
+ "@opentui/solid": "0.1.101",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -386,7 +387,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
- "gitlab-ai-provider": "6.4.2",
+ "gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -404,7 +405,6 @@
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
- "ripgrep": "0.3.1",
"semver": "^7.6.3",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
@@ -458,23 +458,23 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
- "@opentui/core": "0.1.99",
- "@opentui/solid": "0.1.99",
+ "@opentui/core": "0.1.101",
+ "@opentui/solid": "0.1.101",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
- "@opentui/core": ">=0.1.99",
- "@opentui/solid": ">=0.1.99",
+ "@opentui/core": ">=0.1.101",
+ "@opentui/solid": ">=0.1.101",
},
"optionalPeers": [
"@opentui/core",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
- "version": "1.4.7",
+ "version": "1.14.18",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -675,6 +675,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -690,7 +692,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
- "ai": "6.0.158",
+ "ai": "6.0.168",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
@@ -738,7 +740,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
- "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="],
+ "@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
"@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -758,11 +760,11 @@
"@ai-sdk/fireworks": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
- "@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
+ "@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
- "@ai-sdk/google-vertex": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="],
+ "@ai-sdk/google-vertex": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@ai-sdk/groq": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -1586,7 +1588,7 @@
"@opentelemetry/otlp-transformer": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
- "@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+ "@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
@@ -1598,21 +1600,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/[email protected]", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
- "@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="],
+ "@opentui/core": ["@opentui/[email protected]", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.101", "@opentui/core-darwin-x64": "0.1.101", "@opentui/core-linux-arm64": "0.1.101", "@opentui/core-linux-x64": "0.1.101", "@opentui/core-win32-arm64": "0.1.101", "@opentui/core-win32-x64": "0.1.101", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8jUhNKnwCDO3Y2iiEmagoQLjgX5l1WbddQiwky8B5JU4FW0/WRHairBmU1kRAQBmhdeg57dVinSG4iu2PAtKEA=="],
- "@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
+ "@opentui/core-darwin-arm64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HtqZh8TIKCH1Nge5J0etBCpzYfPY4fVcq110uJm2As6D/dTTPv8r4J+KkrqoSphkpj/Y2b4t7KpqNHthXA0EVw=="],
- "@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
+ "@opentui/core-darwin-x64": ["@opentui/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-o5ClQWnGG1inRE2YZAatPw1jPEAJni00amcoIfKBj8e1WS+fQA+iQTq1xFunNcyNPObLDCVuW1X+NrbK9xmPvQ=="],
- "@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
+ "@opentui/core-linux-arm64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/weY7DQpaPWGYDPD0CROHowUotqnVlk7Kb6l9+iZCrxm9s7HPRHkcMDVmcWDqHEqa/J879EJcqaUDzDArqC+w=="],
- "@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
+ "@opentui/core-linux-x64": ["@opentui/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+Bfr8jLbbR1WREUMCCvSZ44G1+WU2lPqJx7x1StTa9iFNEdicxCdd0QQsO6cnKn5yW+2Pr/FdrqHbxSQw3ejbA=="],
- "@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
+ "@opentui/core-win32-arm64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-LTMIHJzJrVqS8mgpp+tuyVHuqYlicQTvFi/sTsJ6Xswf1asatsvZYsbQByhBLpFT80j10G7uvDa361S5gjCUDA=="],
- "@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
+ "@opentui/core-win32-x64": ["@opentui/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-VaMs5bg6y0tYKptaEK8Hy5wTp4m//wJRKUdW8uvrS9cFgxyovZGuw0+TfK3NgbdeX+8jWm8LEAiak4jle5BABg=="],
- "@opentui/solid": ["@opentui/[email protected]", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="],
+ "@opentui/solid": ["@opentui/[email protected]", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.101", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-STY2FQYtVS2rhUgpslG6mM0EAkgobBDF91+B+SNmvXIkJwP+ydP6UVgcuIo5McIbb9GIbAODx5X2Q48PSR7hgw=="],
"@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2454,7 +2456,7 @@
"@valibot/to-json-schema": ["@valibot/[email protected]", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
- "@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
+ "@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -2514,7 +2516,7 @@
"agentkeepalive": ["[email protected]", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
- "ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
+ "ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
"ai-gateway-provider": ["[email protected]", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@@ -3024,7 +3026,7 @@
"ejs": ["[email protected]", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
- "electron": ["[email protected]", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-N1ZXybQZL8kYemO8vAeh9nrk4mSvqlAO8xs0QCHkXIvRnuB/7VGwEehjvQbsU5/f4bmTKpG+2GQERe/zmKpudQ=="],
+ "electron": ["[email protected]", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="],
"electron-builder": ["[email protected]", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="],
@@ -3304,7 +3306,7 @@
"get-tsconfig": ["[email protected]", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-J87BxkLXykmisLQ+KA4x2+O6rVf+PJrtFUO8lGyiRg4lyxJLJ8/v0sRAKdVZQOy6tR6lMRAF1NqzCf9BQijm0w=="],
- "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
+ "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
"gifwrap": ["[email protected]", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -3312,7 +3314,7 @@
"github-slugger": ["[email protected]", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
- "gitlab-ai-provider": ["[email protected]", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
+ "gitlab-ai-provider": ["[email protected]", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
"glob": ["[email protected]", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -4480,8 +4482,6 @@
"rimraf": ["[email protected]", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
- "ripgrep": ["[email protected]", "", { "bin": { "rg": "lib/rg.mjs", "ripgrep": "lib/rg.mjs" } }, "sha512-6bDtNIBh1qPviVIU685/4uv0Ap5t8eS4wiJhy/tR2LdIeIey9CVasENlGS+ul3HnTmGANIp7AjnfsztsRmALfQ=="],
-
"roarr": ["[email protected]", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
@@ -5152,7 +5152,7 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
- "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
+ "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/[email protected]", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
@@ -5170,7 +5170,7 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
- "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
+ "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
@@ -5582,6 +5582,18 @@
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
+ "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+ "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+ "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
+
+ "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+ "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
+ "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/[email protected]", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
+
"@opentui/solid/@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["[email protected]", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
@@ -5688,8 +5700,6 @@
"accepts/negotiator": ["[email protected]", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
- "ai/@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
-
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
@@ -5908,7 +5918,7 @@
"nypm/tinyexec": ["[email protected]", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
- "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
+ "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
diff --git a/infra/console.ts b/infra/console.ts
index 8925f37d5..f1f5692b7 100644
--- a/infra/console.ts
+++ b/infra/console.ts
@@ -236,7 +236,6 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_INSTANCE_URL,
ZEN_BLACK_PRICE,
ZEN_LITE_PRICE,
- new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
new sst.Secret("ZEN_LIMITS"),
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
diff --git a/nix/hashes.json b/nix/hashes.json
index 239b72fd7..8997e133c 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
- "aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
- "aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
- "x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
+ "x86_64-linux": "sha256-YcVW8AGN3TP34CoBoCw+Fx30RL1aveNvxr5eoeOgYeg=",
+ "aarch64-linux": "sha256-G/J3YFfrpEwXSHa25kNyUhYpwPhzNIZf/4v+RCfuslk=",
+ "aarch64-darwin": "sha256-dNPYrGWKoafk4rHqc34U34TtiJGk87yUv5tKnliQcWs=",
+ "x86_64-darwin": "sha256-1LStvefCajGkbdXobMpk0IQyw9SQcQgGKE+U3Fc0Osw="
}
}
diff --git a/nix/opencode.nix b/nix/opencode.nix
index 4deac157e..b629d0b55 100644
--- a/nix/opencode.nix
+++ b/nix/opencode.nix
@@ -7,6 +7,7 @@
sysctl,
makeBinaryWrapper,
models-dev,
+ ripgrep,
installShellFiles,
versionCheckHook,
writableTmpDirAsHomeHook,
@@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postBuild
'';
- installPhase =
- ''
- runHook preInstall
-
- install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
- install -Dm644 schema.json $out/share/opencode/schema.json
- ''
- # bun runs sysctl to detect if dunning on rosetta2
- + lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
- wrapProgram $out/bin/opencode \
- --prefix PATH : ${
- lib.makeBinPath [
- sysctl
+ installPhase = ''
+ runHook preInstall
+
+ install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
+ install -Dm644 schema.json $out/share/opencode/schema.json
+
+ wrapProgram $out/bin/opencode \
+ --prefix PATH : ${
+ lib.makeBinPath (
+ [
+ ripgrep
]
- }
- ''
- + ''
- runHook postInstall
- '';
+ # bun runs sysctl to detect if dunning on rosetta2
+ ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
+ )
+ }
+
+ runHook postInstall
+ '';
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
# trick yargs into also generating zsh completions
diff --git a/package.json b/package.json
index 5fecc0992..063226ad0 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"packageManager": "[email protected]",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
- "dev:desktop": "bun --cwd packages/desktop tauri dev",
+ "dev:desktop": "bun --cwd packages/desktop-electron dev",
"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",
@@ -34,6 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
+ "@opentui/core": "0.1.99",
+ "@opentui/solid": "0.1.99",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -51,7 +53,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
- "ai": "6.0.158",
+ "ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
diff --git a/packages/app/package.json b/packages/app/package.json
index 2941637d0..a3081798a 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
- "version": "1.4.7",
+ "version": "1.14.18",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2
new file mode 100644
index 000000000..02a57c6f5
--- /dev/null
+++ b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2
Binary files differ
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index c380fb69b..490bc2e48 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -19,6 +19,9 @@ import {
sansDefault,
sansFontFamily,
sansInput,
+ terminalDefault,
+ terminalFontFamily,
+ terminalInput,
useSettings,
} from "@/context/settings"
import { decode64 } from "@/utils/base64"
@@ -181,6 +184,7 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
+ const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
@@ -451,6 +455,29 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
+
+ <SettingsRow
+ title={language.t("settings.general.row.terminalFont.title")}
+ description={language.t("settings.general.row.terminalFont.description")}
+ >
+ <div class="w-full sm:w-[220px]">
+ <TextField
+ data-action="settings-terminal-font"
+ label={language.t("settings.general.row.terminalFont.title")}
+ hideLabel
+ type="text"
+ value={terminal()}
+ onChange={(value) => settings.appearance.setTerminalFont(value)}
+ placeholder={terminalDefault}
+ spellcheck={false}
+ autocorrect="off"
+ autocomplete="off"
+ autocapitalize="off"
+ class="text-12-regular"
+ style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
+ />
+ </div>
+ </SettingsRow>
</SettingsList>
</div>
)
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 57e91d6d3..ff5ff9dad 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
-import { monoFontFamily, useSettings } from "@/context/settings"
+import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => {
})
createEffect(() => {
- const font = monoFontFamily(settings.appearance.font())
+ const font = terminalFontFamily(settings.appearance.terminalFont())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => {
cols: restoreSize?.cols,
rows: restoreSize?.rows,
fontSize: 14,
- fontFamily: monoFontFamily(settings.appearance.font()),
+ fontFamily: terminalFontFamily(settings.appearance.terminalFont()),
allowTransparency: false,
convertEol: false,
theme: terminalColors(),
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 6ff60f161..1a672639b 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -204,7 +204,7 @@ function createGlobalSync() {
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
- .ensureQueryData({
+ .fetchQuery({
...loadSessionsQuery(directory),
queryFn: () =>
loadRootSessionsWithFallback({
@@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
- child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory)
if (!cache) return
const sdk = sdkFor(directory)
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 6788e8cc5..3fe67e4fb 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
- bootstrapPromise: Promise.resolve(),
})
children[directory] = child
disposers.set(directory, dispose)
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
index 28b3705d1..e3ec83c5e 100644
--- a/packages/app/src/context/global-sync/types.ts
+++ b/packages/app/src/context/global-sync/types.ts
@@ -72,7 +72,6 @@ export type State = {
part: {
[messageID: string]: Part[]
}
- bootstrapPromise: Promise<void>
}
export type VcsCache = {
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index a585789ce..6d4f3d2cd 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -39,6 +39,7 @@ export interface Settings {
fontSize: number
mono: string
sans: string
+ terminal: string
}
keybinds: Record<string, string>
permissions: {
@@ -50,13 +51,17 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
+export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
+const terminalFallback =
+ '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoBase = monoFallback
const sansBase = sansFallback
+const terminalBase = terminalFallback
function input(font: string | undefined) {
return font ?? ""
@@ -89,6 +94,14 @@ export function sansFontFamily(font: string | undefined) {
return stack(font, sansBase)
}
+export function terminalInput(font: string | undefined) {
+ return input(font)
+}
+
+export function terminalFontFamily(font: string | undefined) {
+ return stack(font, terminalBase)
+}
+
const defaultSettings: Settings = {
general: {
autoSave: true,
@@ -110,6 +123,7 @@ const defaultSettings: Settings = {
fontSize: 14,
mono: "",
sans: "",
+ terminal: "",
},
keybinds: {},
permissions: {
@@ -233,6 +247,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setUIFont(value: string) {
setStore("appearance", "sans", value.trim() ? value : "")
},
+ terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
+ setTerminalFont(value: string) {
+ setStore("appearance", "terminal", value.trim() ? value : "")
+ },
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 6c3f3bb55..9e9a88c2d 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -565,7 +565,9 @@ export const dict = {
"settings.general.row.theme.title": "السمة",
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "خط الكود",
- "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
+ "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "خط الواجهة",
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
"settings.general.row.followup.title": "سلوك المتابعة",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 63880462a..5fd1aee76 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -572,7 +572,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte de código",
- "settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
+ "settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Fonte da interface",
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
"settings.general.row.followup.title": "Comportamento de acompanhamento",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index 2b589eb35..f872db1f0 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -637,7 +637,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
"settings.general.row.font.title": "Font za kod",
- "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
+ "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI font",
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index b096d87b4..82f4fe3f6 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -632,7 +632,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
"settings.general.row.font.title": "Kode-skrifttype",
- "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
+ "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-skrifttype",
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
"settings.general.row.followup.title": "Opfølgningsadfærd",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 6dc0b0497..d5b95459a 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -582,7 +582,9 @@ export const dict = {
"settings.general.row.theme.title": "Thema",
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
"settings.general.row.font.title": "Code-Schriftart",
- "settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
+ "settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-Schriftart",
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 2cfb79714..8a2fbf87f 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -735,7 +735,9 @@ export const dict = {
"settings.general.row.theme.title": "Theme",
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Code Font",
- "settings.general.row.font.description": "Customise the font used in code blocks and terminals",
+ "settings.general.row.font.description": "Customise the font used in code blocks",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI Font",
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.followup.title": "Follow-up behavior",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index c600232ef..12bc45cf3 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -640,7 +640,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
"settings.general.row.font.title": "Fuente de código",
- "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
+ "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Fuente de la interfaz",
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
"settings.general.row.followup.title": "Comportamiento de seguimiento",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index a140c1e3a..6c98b9ca1 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -579,7 +579,9 @@ export const dict = {
"settings.general.row.theme.title": "Thème",
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
"settings.general.row.font.title": "Police de code",
- "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
+ "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Police de l'interface",
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
"settings.general.row.followup.title": "Comportement de suivi",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index 3da1c4b43..767833412 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -569,7 +569,9 @@ export const dict = {
"settings.general.row.theme.title": "テーマ",
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
"settings.general.row.font.title": "コードフォント",
- "settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
+ "settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UIフォント",
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
"settings.general.row.followup.title": "フォローアップの動作",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 2b5ccd43d..76bf33df6 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -566,7 +566,9 @@ export const dict = {
"settings.general.row.theme.title": "테마",
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
"settings.general.row.font.title": "코드 글꼴",
- "settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
+ "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI 글꼴",
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
"settings.general.row.followup.title": "후속 조치 동작",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index a0a968179..75e557b16 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -640,7 +640,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
"settings.general.row.font.title": "Kodefont",
- "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
+ "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "UI-skrift",
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
"settings.general.row.followup.title": "Oppfølgingsadferd",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index 88d209f11..0ab4a6906 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -571,7 +571,9 @@ export const dict = {
"settings.general.row.theme.title": "Motyw",
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
"settings.general.row.font.title": "Czcionka kodu",
- "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
+ "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Czcionka interfejsu",
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
"settings.general.row.followup.title": "Zachowanie kontynuacji",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 688289b7e..135c8e66c 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -637,7 +637,9 @@ export const dict = {
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
"settings.general.row.font.title": "Шрифт кода",
- "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
+ "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Шрифт интерфейса",
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 5decf3adb..81674df32 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -631,7 +631,9 @@ export const dict = {
"settings.general.row.theme.title": "ธีม",
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
"settings.general.row.font.title": "ฟอนต์โค้ด",
- "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
+ "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "ฟอนต์ UI",
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index 6a3ade0d0..f3cb3ab46 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/packages/app/src/i18n/tr.ts
@@ -644,7 +644,9 @@ export const dict = {
"settings.general.row.theme.title": "Tema",
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
"settings.general.row.font.title": "Kod Yazı Tipi",
- "settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
+ "settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
"settings.general.row.followup.title": "Takip davranışı",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 28231733e..d95bfd19b 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -631,7 +631,9 @@ export const dict = {
"settings.general.row.theme.title": "主题",
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
"settings.general.row.font.title": "代码字体",
- "settings.general.row.font.description": "自定义代码块和终端使用的字体",
+ "settings.general.row.font.description": "自定义代码块使用的字体",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "界面字体",
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
"settings.general.row.followup.title": "跟进消息行为",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 4abdf5db5..4a88ca4fc 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -626,7 +626,9 @@ export const dict = {
"settings.general.row.theme.title": "主題",
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
"settings.general.row.font.title": "程式碼字型",
- "settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
+ "settings.general.row.font.description": "自訂程式碼區塊使用的字型",
+ "settings.general.row.terminalFont.title": "Terminal Font",
+ "settings.general.row.terminalFont.description": "Customise the font used in the terminal",
"settings.general.row.uiFont.title": "介面字型",
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
"settings.general.row.followup.title": "後續追問行為",
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index 629ac80a8..9fbc5be70 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -1,5 +1,12 @@
@import "@opencode-ai/ui/styles/tailwind";
+@font-face {
+ font-family: "JetBrainsMono Nerd Font Mono";
+ src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
+ font-weight: normal;
+ font-style: normal;
+}
+
@layer components {
@keyframes session-progress-whip {
0% {
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 12a2bf763..3d3bd5e97 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
+ const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
</Show>
}
>
- <>
- <div class="shrink-0 pl-1 py-1">
- <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
- <div class="flex flex-col min-w-0">
- <InlineEditor
- id={`project:${projectId()}`}
- value={projectName}
- onSave={(next) => {
- const item = project()
- if (!item) return
- void renameProject(item, next)
- }}
- class="text-14-medium text-text-strong truncate"
- displayClass="text-14-medium text-text-strong truncate"
- stopPropagation
- />
-
- <Tooltip
- placement="bottom"
- gutter={2}
- value={worktree()}
- class="shrink-0"
- contentStyle={{
- "max-width": "640px",
- transform: "translate3d(52px, 0, 0)",
- }}
- >
- <span class="text-12-regular text-text-base truncate select-text">
- {worktree().replace(homedir(), "~")}
- </span>
- </Tooltip>
- </div>
+ {(project) => (
+ <>
+ <div class="shrink-0 pl-1 py-1">
+ <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
+ <div class="flex flex-col min-w-0">
+ <InlineEditor
+ id={`project:${projectId()}`}
+ value={projectName}
+ onSave={(next) => {
+ const item = project()
+ if (!item) return
+ void renameProject(item, next)
+ }}
+ class="text-14-medium text-text-strong truncate"
+ displayClass="text-14-medium text-text-strong truncate"
+ stopPropagation
+ />
+
+ <Tooltip
+ placement="bottom"
+ gutter={2}
+ value={worktree()}
+ class="shrink-0"
+ contentStyle={{
+ "max-width": "640px",
+ transform: "translate3d(52px, 0, 0)",
+ }}
+ >
+ <span class="text-12-regular text-text-base truncate select-text">
+ {worktree().replace(homedir(), "~")}
+ </span>
+ </Tooltip>
+ </div>
- <DropdownMenu modal={!sidebarHovering()}>
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- data-action="project-menu"
- data-project={slug()}
- class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
- classList={{
- "opacity-100": panelProps.mobile || merged(),
- "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
- !panelProps.mobile && !merged(),
- }}
- aria-label={language.t("common.moreOptions")}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item
- onSelect={() => {
- const item = project()
- if (!item) return
- showEditProjectDialog(item)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-workspaces-toggle"
- data-project={slug()}
- disabled={!canToggle()}
- onSelect={() => {
- const item = project()
- if (!item) return
- toggleProjectWorkspaces(item)
- }}
- >
- <DropdownMenu.ItemLabel>
- {workspacesEnabled()
- ? language.t("sidebar.workspaces.disable")
- : language.t("sidebar.workspaces.enable")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Item
- data-action="project-clear-notifications"
- data-project={slug()}
- disabled={unseenCount() === 0}
- onSelect={clearNotifications}
- >
- <DropdownMenu.ItemLabel>
- {language.t("sidebar.project.clearNotifications")}
- </DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- data-action="project-close-menu"
- data-project={slug()}
- onSelect={() => {
- const dir = worktree()
- if (!dir) return
- closeProject(dir)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
+ <DropdownMenu modal={!sidebarHovering()}>
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ data-action="project-menu"
+ data-project={slug()}
+ class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
+ classList={{
+ "opacity-100": panelProps.mobile || merged(),
+ "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
+ !panelProps.mobile && !merged(),
+ }}
+ aria-label={language.t("common.moreOptions")}
+ />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content class="mt-1">
+ <DropdownMenu.Item
+ onSelect={() => {
+ const item = project()
+ if (!item) return
+ showEditProjectDialog(item)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={slug()}
+ disabled={!canToggle()}
+ onSelect={() => {
+ const item = project()
+ if (!item) return
+ toggleProjectWorkspaces(item)
+ }}
+ >
+ <DropdownMenu.ItemLabel>
+ {workspacesEnabled()
+ ? language.t("sidebar.workspaces.disable")
+ : language.t("sidebar.workspaces.enable")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item
+ data-action="project-clear-notifications"
+ data-project={slug()}
+ disabled={unseenCount() === 0}
+ onSelect={clearNotifications}
+ >
+ <DropdownMenu.ItemLabel>
+ {language.t("sidebar.project.clearNotifications")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item
+ data-action="project-close-menu"
+ data-project={slug()}
+ onSelect={() => {
+ const dir = worktree()
+ if (!dir) return
+ closeProject(dir)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
</div>
- </div>
- <div class="flex-1 min-h-0 flex flex-col">
- <Show
- when={workspacesEnabled()}
- fallback={
+ <div class="flex-1 min-h-0 flex flex-col">
+ <Show
+ when={workspacesEnabled()}
+ fallback={
+ <>
+ <div class="shrink-0 py-4">
+ <Button
+ size="large"
+ icon="new-session"
+ class="w-full"
+ onClick={() => {
+ const dir = worktree()
+ if (!dir) return
+ navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
+ }}
+ >
+ {language.t("command.session.new")}
+ </Button>
+ </div>
+ <div class="flex-1 min-h-0">
+ <LocalWorkspace
+ ctx={workspaceSidebarCtx}
+ project={project()}
+ sortNow={sortNow}
+ mobile={panelProps.mobile}
+ />
+ </div>
+ </>
+ }
+ >
<>
<div class="shrink-0 py-4">
<Button
size="large"
- icon="new-session"
+ icon="plus-small"
class="w-full"
onClick={() => {
- const dir = worktree()
- if (!dir) return
- navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
+ const item = project()
+ if (!item) return
+ void createWorkspace(item)
}}
>
- {language.t("command.session.new")}
+ {language.t("workspace.new")}
</Button>
</div>
- <div class="flex-1 min-h-0">
- <LocalWorkspace
- ctx={workspaceSidebarCtx}
- project={project()!}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- />
+ <div class="relative flex-1 min-h-0">
+ <DragDropProvider
+ onDragStart={handleWorkspaceDragStart}
+ onDragEnd={handleWorkspaceDragEnd}
+ onDragOver={handleWorkspaceDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div
+ ref={(el) => {
+ if (!panelProps.mobile) scrollContainerRef = el
+ }}
+ class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+ >
+ <SortableProvider ids={workspaces()}>
+ <For each={workspaces()}>
+ {(directory) => (
+ <SortableWorkspace
+ ctx={workspaceSidebarCtx}
+ directory={directory}
+ project={project()}
+ sortNow={sortNow}
+ mobile={panelProps.mobile}
+ />
+ )}
+ </For>
+ </SortableProvider>
+ </div>
+ <DragOverlay>
+ <WorkspaceDragOverlay
+ sidebarProject={sidebarProject}
+ activeWorkspace={() => store.activeWorkspace}
+ workspaceLabel={workspaceLabel}
+ />
+ </DragOverlay>
+ </DragDropProvider>
</div>
</>
- }
- >
- <>
- <div class="shrink-0 py-4">
- <Button
- size="large"
- icon="plus-small"
- class="w-full"
- onClick={() => {
- const item = project()
- if (!item) return
- void createWorkspace(item)
- }}
- >
- {language.t("workspace.new")}
- </Button>
- </div>
- <div class="relative flex-1 min-h-0">
- <DragDropProvider
- onDragStart={handleWorkspaceDragStart}
- onDragEnd={handleWorkspaceDragEnd}
- onDragOver={handleWorkspaceDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={(el) => {
- if (!panelProps.mobile) scrollContainerRef = el
- }}
- class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
- >
- <SortableProvider ids={workspaces()}>
- <For each={workspaces()}>
- {(directory) => (
- <SortableWorkspace
- ctx={workspaceSidebarCtx}
- directory={directory}
- project={project()!}
- sortNow={sortNow}
- mobile={panelProps.mobile}
- />
- )}
- </For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <WorkspaceDragOverlay
- sidebarProject={sidebarProject}
- activeWorkspace={() => store.activeWorkspace}
- workspaceLabel={workspaceLabel}
- />
- </DragOverlay>
- </DragDropProvider>
- </div>
- </>
- </Show>
- </div>
- </>
+ </Show>
+ </div>
+ </>
+ )}
</Show>
<div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
/>
)
- const [loading] = createResource(
- () => route()?.store?.[0]?.bootstrapPromise,
- (p) => p,
- )
-
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
- {(autoselecting(), loading()) ?? ""}
+ {autoselecting() ?? ""}
<Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index c1836fa8a..0202cfc3b 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
- const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
+ const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
- const wasBusy = createMemo((prev) => prev || busy(), false)
- const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
+ const loading = () => query.isLoading
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={showNew}
- loading={loading}
+ loading={() => query.isLoading && count() === 0}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
- const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
- const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
+ const loading = () => query.isLoading && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={() => false}
- loading={() => query.isLoading}
+ loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index 06cbec48b..99197f0a7 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -52,7 +52,12 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
- const shown = createMemo(() => platform.platform !== "desktop" || settings.general.showFileTree())
+ const shown = createMemo(
+ () =>
+ platform.platform !== "desktop" ||
+ import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
+ settings.general.showFileTree(),
+ )
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 9bbeb10bd..d649aeb0c 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -70,7 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
- const shown = () => platform.platform !== "desktop" || settings.general.showFileTree()
+ const shown = () =>
+ platform.platform !== "desktop" ||
+ import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
+ settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index dce0e94c3..0cac30cb1 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -469,7 +469,7 @@ export function persisted<T>(
state,
setState,
init,
- Object.assign(() => ready() === true, {
+ Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined,
}),
]
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 8783f3fd0..6a837c373 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts
index 11f76ca59..f0fdf2180 100644
--- a/packages/console/app/src/i18n/ar.ts
+++ b/packages/console/app/src/i18n/ar.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "المؤسسات",
"nav.zen": "Zen",
"nav.login": "تسجيل الدخول",
- "nav.free": "مجانا",
+ "nav.free": "تحميل",
"nav.home": "الرئيسية",
"nav.openMenu": "فتح القائمة",
"nav.getStartedFree": "ابدأ مجانا",
@@ -558,6 +558,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
+ "workspace.redeem.title": "استرداد قسيمة",
+ "workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.",
+ "workspace.redeem.placeholder": "أدخل رمز القسيمة",
+ "workspace.redeem.redeem": "استرداد",
+ "workspace.redeem.redeeming": "جارٍ الاسترداد...",
+ "workspace.redeem.success": "تم استرداد القسيمة بنجاح.",
+
"workspace.reload.title": "إعادة الشحن التلقائي",
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
"workspace.reload.disabled.state": "معطّل",
diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts
index 79f64485a..fa479288b 100644
--- a/packages/console/app/src/i18n/br.ts
+++ b/packages/console/app/src/i18n/br.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Entrar",
- "nav.free": "Grátis",
+ "nav.free": "Download",
"nav.home": "Início",
"nav.openMenu": "Abrir menu",
"nav.getStartedFree": "Começar grátis",
@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
+ "workspace.redeem.title": "Resgatar Cupom",
+ "workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.",
+ "workspace.redeem.placeholder": "Digite o código do cupom",
+ "workspace.redeem.redeem": "Resgatar",
+ "workspace.redeem.redeeming": "Resgatando...",
+ "workspace.redeem.success": "Cupom resgatado com sucesso.",
+
"workspace.reload.title": "Recarga Automática",
"workspace.reload.disabled.before": "A recarga automática está",
"workspace.reload.disabled.state": "desativada",
diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts
index 97b148c06..9814ece9b 100644
--- a/packages/console/app/src/i18n/da.ts
+++ b/packages/console/app/src/i18n/da.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Log ind",
- "nav.free": "Gratis",
+ "nav.free": "Download",
"nav.home": "Hjem",
"nav.openMenu": "Åbn menu",
"nav.getStartedFree": "Kom i gang gratis",
@@ -563,6 +563,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
+ "workspace.redeem.title": "Indløs kupon",
+ "workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.",
+ "workspace.redeem.placeholder": "Indtast kuponkode",
+ "workspace.redeem.redeem": "Indløs",
+ "workspace.redeem.redeeming": "Indløser...",
+ "workspace.redeem.success": "Kuponen blev indløst.",
+
"workspace.reload.title": "Automatisk genopfyldning",
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
"workspace.reload.disabled.state": "deaktiveret",
diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts
index 1c5f631ae..aa7361493 100644
--- a/packages/console/app/src/i18n/de.ts
+++ b/packages/console/app/src/i18n/de.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Anmelden",
- "nav.free": "Kostenlos",
+ "nav.free": "Download",
"nav.home": "Startseite",
"nav.openMenu": "Menü öffnen",
"nav.getStartedFree": "Kostenlos starten",
@@ -566,6 +566,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
+ "workspace.redeem.title": "Gutschein einlösen",
+ "workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.",
+ "workspace.redeem.placeholder": "Gutscheincode eingeben",
+ "workspace.redeem.redeem": "Einlösen",
+ "workspace.redeem.redeeming": "Wird eingelöst...",
+ "workspace.redeem.success": "Gutschein erfolgreich eingelöst.",
+
"workspace.reload.title": "Auto-Reload",
"workspace.reload.disabled.before": "Auto-Reload ist",
"workspace.reload.disabled.state": "deaktiviert",
diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts
index d21d0dc9e..86119a560 100644
--- a/packages/console/app/src/i18n/en.ts
+++ b/packages/console/app/src/i18n/en.ts
@@ -8,7 +8,7 @@ export const dict = {
"nav.zen": "Zen",
"nav.go": "Go",
"nav.login": "Login",
- "nav.free": "Free",
+ "nav.free": "Download",
"nav.home": "Home",
"nav.openMenu": "Open menu",
"nav.getStartedFree": "Get started for free",
@@ -559,6 +559,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
+ "workspace.redeem.title": "Redeem Coupon",
+ "workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.",
+ "workspace.redeem.placeholder": "Enter coupon code",
+ "workspace.redeem.redeem": "Redeem",
+ "workspace.redeem.redeeming": "Redeeming...",
+ "workspace.redeem.success": "Coupon redeemed successfully.",
+
"workspace.reload.title": "Auto Reload",
"workspace.reload.disabled.before": "Auto reload is",
"workspace.reload.disabled.state": "disabled",
diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts
index d6449fd95..bde2bc988 100644
--- a/packages/console/app/src/i18n/es.ts
+++ b/packages/console/app/src/i18n/es.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Iniciar sesión",
- "nav.free": "Gratis",
+ "nav.free": "Descargar",
"nav.home": "Inicio",
"nav.openMenu": "Abrir menú",
"nav.getStartedFree": "Empezar gratis",
@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
+ "workspace.redeem.title": "Canjear cupón",
+ "workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.",
+ "workspace.redeem.placeholder": "Introduce el código del cupón",
+ "workspace.redeem.redeem": "Canjear",
+ "workspace.redeem.redeeming": "Canjeando...",
+ "workspace.redeem.success": "Cupón canjeado correctamente.",
+
"workspace.reload.title": "Auto Recarga",
"workspace.reload.disabled.before": "La auto recarga está",
"workspace.reload.disabled.state": "deshabilitada",
diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts
index c54c43104..867390027 100644
--- a/packages/console/app/src/i18n/fr.ts
+++ b/packages/console/app/src/i18n/fr.ts
@@ -12,7 +12,7 @@ export const dict = {
"nav.enterprise": "Entreprise",
"nav.zen": "Zen",
"nav.login": "Se connecter",
- "nav.free": "Gratuit",
+ "nav.free": "Télécharger",
"nav.home": "Accueil",
"nav.openMenu": "Ouvrir le menu",
"nav.getStartedFree": "Commencer gratuitement",
@@ -569,6 +569,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
+ "workspace.redeem.title": "Utiliser un coupon",
+ "workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.",
+ "workspace.redeem.placeholder": "Saisissez le code promo",
+ "workspace.redeem.redeem": "Utiliser",
+ "workspace.redeem.redeeming": "Utilisation...",
+ "workspace.redeem.success": "Coupon utilisé avec succès.",
+
"workspace.reload.title": "Rechargement automatique",
"workspace.reload.disabled.before": "Le rechargement automatique est",
"workspace.reload.disabled.state": "désactivé",
diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts
index aadea6fec..3ca1935dd 100644
--- a/packages/console/app/src/i18n/it.ts
+++ b/packages/console/app/src/i18n/it.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Accedi",
- "nav.free": "Gratis",
+ "nav.free": "Scarica",
"nav.home": "Home",
"nav.openMenu": "Apri menu",
"nav.getStartedFree": "Inizia gratis",
@@ -565,6 +565,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
+ "workspace.redeem.title": "Riscatta Coupon",
+ "workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.",
+ "workspace.redeem.placeholder": "Inserisci il codice coupon",
+ "workspace.redeem.redeem": "Riscatta",
+ "workspace.redeem.redeeming": "Riscatto in corso...",
+ "workspace.redeem.success": "Coupon riscattato con successo.",
+
"workspace.reload.title": "Ricarica Auto",
"workspace.reload.disabled.before": "La ricarica auto è",
"workspace.reload.disabled.state": "disabilitata",
diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts
index f3b4c083e..7d13dda95 100644
--- a/packages/console/app/src/i18n/ja.ts
+++ b/packages/console/app/src/i18n/ja.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "エンタープライズ",
"nav.zen": "Zen",
"nav.login": "ログイン",
- "nav.free": "無料",
+ "nav.free": "ダウンロード",
"nav.home": "ホーム",
"nav.openMenu": "メニューを開く",
"nav.getStartedFree": "無料ではじめる",
@@ -564,6 +564,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
+ "workspace.redeem.title": "クーポンを利用",
+ "workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。",
+ "workspace.redeem.placeholder": "クーポンコードを入力",
+ "workspace.redeem.redeem": "利用する",
+ "workspace.redeem.redeeming": "利用中...",
+ "workspace.redeem.success": "クーポンを利用しました。",
+
"workspace.reload.title": "自動チャージ",
"workspace.reload.disabled.before": "自動チャージは",
"workspace.reload.disabled.state": "無効",
diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts
index e2320c359..f9ac2e7f3 100644
--- a/packages/console/app/src/i18n/ko.ts
+++ b/packages/console/app/src/i18n/ko.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "엔터프라이즈",
"nav.zen": "Zen",
"nav.login": "로그인",
- "nav.free": "무료",
+ "nav.free": "다운로드",
"nav.home": "홈",
"nav.openMenu": "메뉴 열기",
"nav.getStartedFree": "무료로 시작하기",
@@ -558,6 +558,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
+ "workspace.redeem.title": "쿠폰 사용",
+ "workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.",
+ "workspace.redeem.placeholder": "쿠폰 코드를 입력하세요",
+ "workspace.redeem.redeem": "사용",
+ "workspace.redeem.redeeming": "사용 중...",
+ "workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.",
+
"workspace.reload.title": "자동 충전",
"workspace.reload.disabled.before": "자동 충전이",
"workspace.reload.disabled.state": "비활성화",
diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts
index 717c2ad1e..b08386f4f 100644
--- a/packages/console/app/src/i18n/no.ts
+++ b/packages/console/app/src/i18n/no.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Logg inn",
- "nav.free": "Gratis",
+ "nav.free": "Last ned",
"nav.home": "Hjem",
"nav.openMenu": "Åpne meny",
"nav.getStartedFree": "Kom i gang gratis",
@@ -564,6 +564,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
+ "workspace.redeem.title": "Løs inn kupong",
+ "workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.",
+ "workspace.redeem.placeholder": "Skriv inn kupongkode",
+ "workspace.redeem.redeem": "Løs inn",
+ "workspace.redeem.redeeming": "Løser inn...",
+ "workspace.redeem.success": "Kupongen ble løst inn.",
+
"workspace.reload.title": "Auto-påfyll",
"workspace.reload.disabled.before": "Auto-påfyll er",
"workspace.reload.disabled.state": "deaktivert",
diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts
index c9daeedee..27d6a9e06 100644
--- a/packages/console/app/src/i18n/pl.ts
+++ b/packages/console/app/src/i18n/pl.ts
@@ -10,7 +10,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Zaloguj się",
- "nav.free": "Darmowe",
+ "nav.free": "Pobierz",
"nav.home": "Strona główna",
"nav.openMenu": "Otwórz menu",
"nav.getStartedFree": "Zacznij za darmo",
@@ -565,6 +565,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
+ "workspace.redeem.title": "Zrealizuj kupon",
+ "workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.",
+ "workspace.redeem.placeholder": "Wpisz kod kuponu",
+ "workspace.redeem.redeem": "Zrealizuj",
+ "workspace.redeem.redeeming": "Realizowanie...",
+ "workspace.redeem.success": "Kupon został zrealizowany.",
+
"workspace.reload.title": "Automatyczne doładowanie",
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
"workspace.reload.disabled.state": "wyłączone",
diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts
index 01baa8985..b4070a963 100644
--- a/packages/console/app/src/i18n/ru.ts
+++ b/packages/console/app/src/i18n/ru.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Enterprise",
"nav.zen": "Zen",
"nav.login": "Войти",
- "nav.free": "Бесплатно",
+ "nav.free": "Скачать",
"nav.home": "Главная",
"nav.openMenu": "Открыть меню",
"nav.getStartedFree": "Начать бесплатно",
@@ -571,6 +571,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
+ "workspace.redeem.title": "Активировать купон",
+ "workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.",
+ "workspace.redeem.placeholder": "Введите код купона",
+ "workspace.redeem.redeem": "Активировать",
+ "workspace.redeem.redeeming": "Активация...",
+ "workspace.redeem.success": "Купон успешно активирован.",
+
"workspace.reload.title": "Автопополнение",
"workspace.reload.disabled.before": "Автопополнение",
"workspace.reload.disabled.state": "отключено",
diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts
index 59c90ef65..9455c983f 100644
--- a/packages/console/app/src/i18n/th.ts
+++ b/packages/console/app/src/i18n/th.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "องค์กร",
"nav.zen": "Zen",
"nav.login": "เข้าสู่ระบบ",
- "nav.free": "ฟรี",
+ "nav.free": "ดาวน์โหลด",
"nav.home": "หน้าหลัก",
"nav.openMenu": "เปิดเมนู",
"nav.getStartedFree": "เริ่มต้นฟรี",
@@ -560,6 +560,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
+ "workspace.redeem.title": "แลกคูปอง",
+ "workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ",
+ "workspace.redeem.placeholder": "กรอกรหัสคูปอง",
+ "workspace.redeem.redeem": "แลก",
+ "workspace.redeem.redeeming": "กำลังแลก...",
+ "workspace.redeem.success": "แลกคูปองสำเร็จ",
+
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",
diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts
index 196bf9d37..a6459b950 100644
--- a/packages/console/app/src/i18n/tr.ts
+++ b/packages/console/app/src/i18n/tr.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "Kurumsal",
"nav.zen": "Zen",
"nav.login": "Giriş",
- "nav.free": "Ücretsiz",
+ "nav.free": "İndir",
"nav.home": "Ana sayfa",
"nav.openMenu": "Menüyü aç",
"nav.getStartedFree": "Ücretsiz başla",
@@ -567,6 +567,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
+ "workspace.redeem.title": "Kupon Kullan",
+ "workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.",
+ "workspace.redeem.placeholder": "Kupon kodunu girin",
+ "workspace.redeem.redeem": "Kullan",
+ "workspace.redeem.redeeming": "Kullanılıyor...",
+ "workspace.redeem.success": "Kupon başarıyla kullanıldı.",
+
"workspace.reload.title": "Otomatik Yeniden Yükleme",
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
"workspace.reload.disabled.state": "devre dışı",
diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts
index aaec74fba..5aa82e6fa 100644
--- a/packages/console/app/src/i18n/zh.ts
+++ b/packages/console/app/src/i18n/zh.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "企业版",
"nav.zen": "Zen",
"nav.login": "登录",
- "nav.free": "免费",
+ "nav.free": "下载",
"nav.home": "首页",
"nav.openMenu": "打开菜单",
"nav.getStartedFree": "免费开始",
@@ -542,6 +542,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
+ "workspace.redeem.title": "兑换优惠券",
+ "workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。",
+ "workspace.redeem.placeholder": "输入优惠码",
+ "workspace.redeem.redeem": "兑换",
+ "workspace.redeem.redeeming": "兑换中...",
+ "workspace.redeem.success": "优惠券兑换成功。",
+
"workspace.reload.title": "自动充值",
"workspace.reload.disabled.before": "自动充值已",
"workspace.reload.disabled.state": "禁用",
diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts
index b3e0fb077..aaaa31386 100644
--- a/packages/console/app/src/i18n/zht.ts
+++ b/packages/console/app/src/i18n/zht.ts
@@ -11,7 +11,7 @@ export const dict = {
"nav.enterprise": "企業",
"nav.zen": "Zen",
"nav.login": "登入",
- "nav.free": "免費",
+ "nav.free": "下載",
"nav.home": "首頁",
"nav.openMenu": "開啟選單",
"nav.getStartedFree": "免費開始使用",
@@ -542,6 +542,13 @@ export const dict = {
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
+ "workspace.redeem.title": "兌換優惠券",
+ "workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。",
+ "workspace.redeem.placeholder": "輸入優惠碼",
+ "workspace.redeem.redeem": "兌換",
+ "workspace.redeem.redeeming": "兌換中...",
+ "workspace.redeem.success": "優惠券兌換成功。",
+
"workspace.reload.title": "自動儲值",
"workspace.reload.disabled.before": "自動儲值已",
"workspace.reload.disabled.state": "停用",
diff --git a/packages/console/app/src/routes/api/enterprise.ts b/packages/console/app/src/routes/api/enterprise.ts
index 1bc4d0eb2..6560a9361 100644
--- a/packages/console/app/src/routes/api/enterprise.ts
+++ b/packages/console/app/src/routes/api/enterprise.ts
@@ -1,5 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { AWS } from "@opencode-ai/console-core/aws.js"
+import { Resource } from "@opencode-ai/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
@@ -14,6 +15,64 @@ interface EnterpriseFormData {
message: string
}
+const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
+
+function splitFullName(fullName: string) {
+ const parts = fullName
+ .trim()
+ .split(/\s+/)
+ .filter((p) => p.length > 0)
+ if (parts.length === 0) return { firstName: "", lastName: "" }
+ if (parts.length === 1) return { firstName: parts[0], lastName: "" }
+ return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
+}
+
+function getEmailOctopusApiKey() {
+ if (process.env.EMAILOCTOPUS_API_KEY) return process.env.EMAILOCTOPUS_API_KEY
+ try {
+ return Resource.EMAILOCTOPUS_API_KEY.value
+ } catch {
+ return
+ }
+}
+
+function subscribe(email: string, fullName: string) {
+ const apiKey = getEmailOctopusApiKey()
+ if (!apiKey) {
+ console.warn("Skipping EmailOctopus subscribe: missing API key")
+ return Promise.resolve(false)
+ }
+
+ const name = splitFullName(fullName)
+ const fields: Record<string, string> = {}
+ if (name.firstName) fields.FirstName = name.firstName
+ if (name.lastName) fields.LastName = name.lastName
+
+ const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
+ if (Object.keys(fields).length) payload.fields = fields
+
+ return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ }).then(
+ (res) => {
+ if (!res.ok) {
+ console.error("EmailOctopus subscribe failed:", res.status, res.statusText)
+ return false
+ }
+ return true
+ },
+ (err) => {
+ console.error("Failed to subscribe enterprise email:", err)
+ return false
+ },
+ )
+}
+
export async function POST(event: APIEvent) {
const dict = i18n(localeFromRequest(event.request))
try {
@@ -41,7 +100,7 @@ ${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
- const [lead, mail] = await Promise.all([
+ const [lead, mail, octopus] = await Promise.all([
createLead({
name: body.name,
role: body.role,
@@ -49,6 +108,9 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
email: body.email,
phone: body.phone,
message: body.message,
+ }).catch((err) => {
+ console.error("Failed to create Salesforce lead:", err)
+ return false
}),
AWS.sendEmail({
@@ -62,9 +124,14 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
return false
},
),
+ subscribe(body.email, body.name),
])
- if (!lead && !mail) {
+ if (!lead && !mail && !octopus) {
+ if (import.meta.env.DEV) {
+ console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
+ return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
+ }
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
index 0d8cf61cf..c28d9ebbb 100644
--- a/packages/console/app/src/routes/stripe/webhook.ts
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -9,6 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
+import { User } from "@opencode-ai/console-core/user.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -109,6 +110,8 @@ export async function POST(input: APIEvent) {
if (type === "lite") {
const workspaceID = body.data.object.metadata?.workspaceID
const userID = body.data.object.metadata?.userID
+ const userEmail = body.data.object.metadata?.userEmail
+ const coupon = body.data.object.metadata?.coupon
const customerID = body.data.object.customer as string
const invoiceID = body.data.object.latest_invoice as string
const subscriptionID = body.data.object.id as string
@@ -156,6 +159,10 @@ export async function POST(input: APIEvent) {
id: Identifier.create("lite"),
userID: userID,
})
+
+ if (userEmail && coupon === LiteData.firstMonth100Coupon) {
+ await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
+ }
})
})
}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
index 4a7dc2488..e6c11c181 100644
--- a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx
@@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section"
import { ReloadSection } from "./reload-section"
import { PaymentSection } from "./payment-section"
import { BlackSection } from "./black-section"
+import { RedeemSection } from "./redeem-section"
import { createMemo, Show } from "solid-js"
import { createAsync, useParams } from "@solidjs/router"
import { queryBillingInfo, querySessionInfo } from "../../common"
@@ -21,6 +22,7 @@ export default function () {
<BlackSection />
</Show>
<BillingSection />
+ <RedeemSection />
<Show when={billingInfo()?.customerID}>
<ReloadSection />
<MonthlyLimitSection />
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css
new file mode 100644
index 000000000..42140e4e8
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.module.css
@@ -0,0 +1,61 @@
+.root {
+ [data-slot="redeem-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ min-width: 20rem;
+ width: fit-content;
+
+ @media (max-width: 30rem) {
+ width: 100%;
+ }
+ }
+
+ [data-slot="redeem-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ [data-slot="input-row"] {
+ display: flex;
+ gap: var(--space-2);
+ align-items: stretch;
+
+ @media (max-width: 30rem) {
+ flex-direction: column;
+ }
+ }
+
+ input {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+
+ [data-slot="form-success"] {
+ color: var(--color-success, var(--color-accent));
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx
new file mode 100644
index 000000000..872e8954b
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/billing/redeem-section.tsx
@@ -0,0 +1,71 @@
+import { json, action, useParams, useSubmission } from "@solidjs/router"
+import { Show } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+import { Billing } from "@opencode-ai/console-core/billing.js"
+import { User } from "@opencode-ai/console-core/user.js"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
+import styles from "./redeem-section.module.css"
+import { queryBillingInfo } from "../../common"
+import { useI18n } from "~/context/i18n"
+import { formError, localizeError } from "~/lib/form-error"
+
+const redeem = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID") as string | null
+ if (!workspaceID) return { error: formError.workspaceRequired }
+ const code = (form.get("code") as string | null)?.trim().toUpperCase()
+ if (!code) return { error: "Coupon code is required." }
+ if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
+
+ return json(
+ await withActor(async () => {
+ const actor = Actor.assert("user")
+ const email = await User.getAuthEmail(actor.properties.userID)
+ if (!email) return { error: "No email on account." }
+ return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
+ .then(() => ({ error: undefined, data: true }))
+ .catch((e) => ({ error: e.message as string }))
+ }, workspaceID),
+ { revalidate: queryBillingInfo.key },
+ )
+}, "billing.redeemCoupon")
+
+export function RedeemSection() {
+ const params = useParams()
+ const i18n = useI18n()
+ const submission = useSubmission(redeem)
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.redeem.title")}</h2>
+ <p>{i18n.t("workspace.redeem.subtitle")}</p>
+ </div>
+ <div data-slot="redeem-container">
+ <form action={redeem} method="post" data-slot="redeem-form">
+ <div data-slot="input-row">
+ <input
+ required
+ data-component="input"
+ name="code"
+ type="text"
+ autocomplete="off"
+ placeholder={i18n.t("workspace.redeem.placeholder")}
+ />
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
+ </button>
+ </div>
+ <Show when={submission.result && (submission.result as any).error}>
+ {(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
+ </Show>
+ <Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
+ <div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
+ </Show>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ </form>
+ </div>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts
index d1c5985a8..81c512b99 100644
--- a/packages/console/app/src/routes/zen/util/handler.ts
+++ b/packages/console/app/src/routes/zen/util/handler.ts
@@ -45,6 +45,7 @@ import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Resource } from "@opencode-ai/console-resource"
import { i18n, type Key } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
+import { createModelTpmLimiter } from "./modelTpmLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -121,6 +122,8 @@ export async function handler(
const authInfo = await authenticate(modelInfo, zenApiKey)
const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource })
+ const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
+ const modelTpmLimits = await modelTpmLimiter?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@@ -133,6 +136,7 @@ export async function handler(
trialProviders,
retry,
stickyProvider,
+ modelTpmLimits,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
@@ -229,6 +233,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(json.usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
+ await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -278,6 +283,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
+ await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@@ -433,12 +439,16 @@ export async function handler(
trialProviders: string[] | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
+ modelTpmLimits: Record<string, number> | undefined,
) {
const modelProvider = (() => {
+ // Byok is top priority b/c if user set their own API key, we should use it
+ // instead of using the sticky provider for the same session
if (authInfo?.provider?.credentials) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
+ // Always use the same provider for the same session
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider
@@ -451,10 +461,20 @@ export async function handler(
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
- const providers = modelInfo.providers
+ const allProviders = modelInfo.providers
.filter((provider) => !provider.disabled)
+ .filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
- .flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
+ .filter((provider) => {
+ if (!provider.tpmLimit) return true
+ const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
+ return usage < provider.tpmLimit * 1_000_000
+ })
+
+ const topPriority = Math.min(...allProviders.map((p) => p.priority))
+ const providers = allProviders
+ .filter((p) => p.priority <= topPriority)
+ .flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip
@@ -742,7 +762,8 @@ export async function handler(
const billing = authInfo.billing
const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
- if (!billing.paymentMethodID) throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
+ if (!billing.paymentMethodID && billing.balance <= 0)
+ throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
if (billing.balance <= 0) throw new CreditsError(t("zen.api.error.insufficientBalance", { billingUrl }))
const now = new Date()
diff --git a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts
new file mode 100644
index 000000000..9a834a1a5
--- /dev/null
+++ b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts
@@ -0,0 +1,51 @@
+import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
+import { UsageInfo } from "./provider/provider"
+
+export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
+ const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
+ if (keys.length === 0) return
+
+ const yyyyMMddHHmm = new Date(Date.now())
+ .toISOString()
+ .replace(/[^0-9]/g, "")
+ .substring(0, 12)
+
+ return {
+ check: async () => {
+ const data = await Database.use((tx) =>
+ tx
+ .select()
+ .from(ModelRateLimitTable)
+ .where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
+ )
+
+ // convert to map of model to count
+ return data.reduce(
+ (acc, curr) => {
+ acc[curr.key] = curr.count
+ return acc
+ },
+ {} as Record<string, number>,
+ )
+ },
+ track: async (id: string, model: string, usageInfo: UsageInfo) => {
+ const key = `${id}/${model}`
+ if (!keys.includes(key)) return
+ const usage =
+ usageInfo.inputTokens +
+ usageInfo.outputTokens +
+ (usageInfo.reasoningTokens ?? 0) +
+ (usageInfo.cacheReadTokens ?? 0) +
+ (usageInfo.cacheWrite5mTokens ?? 0) +
+ (usageInfo.cacheWrite1hTokens ?? 0)
+ if (usage <= 0) return
+ await Database.use((tx) =>
+ tx
+ .insert(ModelRateLimitTable)
+ .values({ key, interval: yyyyMMddHHmm, count: usage })
+ .onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
+ )
+ },
+ }
+}
diff --git a/packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql b/packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql
new file mode 100644
index 000000000..41a4efe68
--- /dev/null
+++ b/packages/console/core/migrations/20260417071612_tidy_diamondback/migration.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `model_rate_limit` (
+ `key` varchar(255) NOT NULL,
+ `interval` varchar(40) NOT NULL,
+ `count` int NOT NULL,
+ CONSTRAINT PRIMARY KEY(`key`,`interval`)
+);
diff --git a/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json
new file mode 100644
index 000000000..2152bfa76
--- /dev/null
+++ b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json
@@ -0,0 +1,2567 @@
+{
+ "version": "6",
+ "dialect": "mysql",
+ "id": "93c492af-c95b-4213-9fc2-38c3dd10374d",
+ "prevIds": ["a09a925d-6cdd-4e7c-b8b1-11c259928b4c"],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "auth",
+ "entityType": "tables"
+ },
+ {
+ "name": "benchmark",
+ "entityType": "tables"
+ },
+ {
+ "name": "billing",
+ "entityType": "tables"
+ },
+ {
+ "name": "lite",
+ "entityType": "tables"
+ },
+ {
+ "name": "payment",
+ "entityType": "tables"
+ },
+ {
+ "name": "subscription",
+ "entityType": "tables"
+ },
+ {
+ "name": "usage",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip",
+ "entityType": "tables"
+ },
+ {
+ "name": "key_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "model_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "key",
+ "entityType": "tables"
+ },
+ {
+ "name": "model",
+ "entityType": "tables"
+ },
+ {
+ "name": "provider",
+ "entityType": "tables"
+ },
+ {
+ "name": "user",
+ "entityType": "tables"
+ },
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "enum('email','github','google')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subject",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "agent",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "mediumtext",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "result",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(32)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_type",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(4)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_last4",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "balance",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "boolean",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_trigger",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_amount",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_locked_till",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "enum('20','100','200')",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_plan",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_booked",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_selected",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite_subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "weekly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_weekly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "invoice_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "amount",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_refunded",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "fixed_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_fixed_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "input_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "output_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reasoning_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_read_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_5m_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_1h_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cost",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(10)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "usage",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(40)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "model_rate_limit"
+ },
+ {
+ "type": "varchar(40)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "model_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "model_rate_limit"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_used",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "credentials",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_seen",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "color",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "enum('admin','member')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "auth",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "benchmark",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "billing",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "lite",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "payment",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "subscription",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "usage",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip", "interval"],
+ "name": "PRIMARY",
+ "table": "ip_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip"],
+ "name": "PRIMARY",
+ "table": "ip",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key", "interval"],
+ "name": "PRIMARY",
+ "table": "key_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key", "interval"],
+ "name": "PRIMARY",
+ "table": "model_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "key",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "model",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "provider",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "provider",
+ "isExpression": false
+ },
+ {
+ "value": "subject",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "provider",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "account_id",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "time_created",
+ "entityType": "indexes",
+ "table": "benchmark"
+ },
+ {
+ "columns": [
+ {
+ "value": "customer_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_customer_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "subscription_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_subscription_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "lite"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "subscription"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "usage_time_created",
+ "entityType": "indexes",
+ "table": "usage"
+ },
+ {
+ "columns": [
+ {
+ "value": "key",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_key",
+ "entityType": "indexes",
+ "table": "key"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "model",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "model_workspace_model",
+ "entityType": "indexes",
+ "table": "model"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "provider",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_provider",
+ "entityType": "indexes",
+ "table": "provider"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "slug",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "slug",
+ "entityType": "indexes",
+ "table": "workspace"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql
new file mode 100644
index 000000000..7b7f7a2eb
--- /dev/null
+++ b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/migration.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `coupon` (
+ `email` varchar(255),
+ `type` enum('BUILDATHON','GOFREEMONTH') NOT NULL,
+ `time_redeemed` timestamp(3),
+ CONSTRAINT PRIMARY KEY(`email`,`type`)
+);
diff --git a/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json
new file mode 100644
index 000000000..44ccb08cc
--- /dev/null
+++ b/packages/console/core/migrations/20260418195905_shocking_marvel_zombies/snapshot.json
@@ -0,0 +1,2619 @@
+{
+ "version": "6",
+ "dialect": "mysql",
+ "id": "18b4281c-1609-47d8-9d51-0b08e3925f2b",
+ "prevIds": ["93c492af-c95b-4213-9fc2-38c3dd10374d"],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "auth",
+ "entityType": "tables"
+ },
+ {
+ "name": "benchmark",
+ "entityType": "tables"
+ },
+ {
+ "name": "billing",
+ "entityType": "tables"
+ },
+ {
+ "name": "coupon",
+ "entityType": "tables"
+ },
+ {
+ "name": "lite",
+ "entityType": "tables"
+ },
+ {
+ "name": "payment",
+ "entityType": "tables"
+ },
+ {
+ "name": "subscription",
+ "entityType": "tables"
+ },
+ {
+ "name": "usage",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "ip",
+ "entityType": "tables"
+ },
+ {
+ "name": "key_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "model_rate_limit",
+ "entityType": "tables"
+ },
+ {
+ "name": "key",
+ "entityType": "tables"
+ },
+ {
+ "name": "model",
+ "entityType": "tables"
+ },
+ {
+ "name": "provider",
+ "entityType": "tables"
+ },
+ {
+ "name": "user",
+ "entityType": "tables"
+ },
+ {
+ "name": "workspace",
+ "entityType": "tables"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "account"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "enum('email','github','google')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subject",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "auth"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "agent",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "mediumtext",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "result",
+ "entityType": "columns",
+ "table": "benchmark"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(32)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_type",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(4)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_method_last4",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "balance",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "boolean",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_trigger",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_amount",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_error",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_reload_locked_till",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "enum('20','100','200')",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "subscription_plan",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_booked",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_subscription_selected",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(28)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite_subscription_id",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "lite",
+ "entityType": "columns",
+ "table": "billing"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "enum('BUILDATHON','GOFREEMONTH')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_redeemed",
+ "entityType": "columns",
+ "table": "coupon"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "weekly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_weekly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_updated",
+ "entityType": "columns",
+ "table": "lite"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "customer_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "invoice_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "payment_id",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "amount",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_refunded",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "payment"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "rolling_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "fixed_usage",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_rolling_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_fixed_updated",
+ "entityType": "columns",
+ "table": "subscription"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "input_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "output_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "reasoning_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_read_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_5m_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cache_write_1h_tokens",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "bigint",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "cost",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "json",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "enrichment",
+ "entityType": "columns",
+ "table": "usage"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(10)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "ip_rate_limit"
+ },
+ {
+ "type": "varchar(45)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "ip",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "usage",
+ "entityType": "columns",
+ "table": "ip"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(40)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "key_rate_limit"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "model_rate_limit"
+ },
+ {
+ "type": "varchar(40)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "interval",
+ "entityType": "columns",
+ "table": "model_rate_limit"
+ },
+ {
+ "type": "int",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "count",
+ "entityType": "columns",
+ "table": "model_rate_limit"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "key",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "user_id",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_used",
+ "entityType": "columns",
+ "table": "key"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "model",
+ "entityType": "columns",
+ "table": "model"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(64)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "provider",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "credentials",
+ "entityType": "columns",
+ "table": "provider"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "workspace_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "account_id",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_seen",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "color",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "enum('admin','member')",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "role",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "int",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_limit",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "bigint",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "monthly_usage",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_monthly_usage_updated",
+ "entityType": "columns",
+ "table": "user"
+ },
+ {
+ "type": "varchar(30)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "varchar(255)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(now())",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": true,
+ "autoIncrement": false,
+ "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "timestamp(3)",
+ "notNull": false,
+ "autoIncrement": false,
+ "default": null,
+ "onUpdateNow": false,
+ "onUpdateNowFsp": null,
+ "charSet": null,
+ "collation": null,
+ "generated": null,
+ "name": "time_deleted",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "account",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "auth",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "benchmark",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "billing",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["email", "type"],
+ "name": "PRIMARY",
+ "table": "coupon",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "lite",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "payment",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "subscription",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "usage",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip", "interval"],
+ "name": "PRIMARY",
+ "table": "ip_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["ip"],
+ "name": "PRIMARY",
+ "table": "ip",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key", "interval"],
+ "name": "PRIMARY",
+ "table": "key_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["key", "interval"],
+ "name": "PRIMARY",
+ "table": "model_rate_limit",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "key",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "model",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "provider",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["workspace_id", "id"],
+ "name": "PRIMARY",
+ "table": "user",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "name": "PRIMARY",
+ "table": "workspace",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "provider",
+ "isExpression": false
+ },
+ {
+ "value": "subject",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "provider",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "account_id",
+ "entityType": "indexes",
+ "table": "auth"
+ },
+ {
+ "columns": [
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "time_created",
+ "entityType": "indexes",
+ "table": "benchmark"
+ },
+ {
+ "columns": [
+ {
+ "value": "customer_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_customer_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "subscription_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_subscription_id",
+ "entityType": "indexes",
+ "table": "billing"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "lite"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "user_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_user_id",
+ "entityType": "indexes",
+ "table": "subscription"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "time_created",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "usage_time_created",
+ "entityType": "indexes",
+ "table": "usage"
+ },
+ {
+ "columns": [
+ {
+ "value": "key",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_key",
+ "entityType": "indexes",
+ "table": "key"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "model",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "model_workspace_model",
+ "entityType": "indexes",
+ "table": "model"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "provider",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "workspace_provider",
+ "entityType": "indexes",
+ "table": "provider"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "workspace_id",
+ "isExpression": false
+ },
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "user_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "account_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_account_id",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "email",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "global_email",
+ "entityType": "indexes",
+ "table": "user"
+ },
+ {
+ "columns": [
+ {
+ "value": "slug",
+ "isExpression": false
+ }
+ ],
+ "isUnique": true,
+ "using": null,
+ "algorithm": null,
+ "lock": null,
+ "nameExplicit": true,
+ "name": "slug",
+ "entityType": "indexes",
+ "table": "workspace"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index cdefd0e60..9b92cf0b2 100644
--- a/packages/console/core/package.json
+++ b/packages/console/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
- "version": "1.4.7",
+ "version": "1.14.18",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/console/core/script/create-coupon.ts b/packages/console/core/script/create-coupon.ts
new file mode 100644
index 000000000..e13337905
--- /dev/null
+++ b/packages/console/core/script/create-coupon.ts
@@ -0,0 +1,24 @@
+import { Database } from "../src/drizzle/index.js"
+import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
+
+const email = process.argv[2]
+const type = process.argv[3] as (typeof CouponType)[number]
+
+if (!email || !type) {
+ console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
+ process.exit(1)
+}
+
+if (!(CouponType as readonly string[]).includes(type)) {
+ console.error(`Error: type must be one of ${CouponType.join(", ")}`)
+ process.exit(1)
+}
+
+await Database.use((tx) =>
+ tx.insert(CouponTable).values({
+ email,
+ type,
+ }),
+)
+
+console.log(`Created ${type} coupon for ${email}`)
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index 9de413e60..a3252f1d7 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -1,6 +1,14 @@
import { Stripe } from "stripe"
-import { Database, eq, sql } from "./drizzle"
-import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
+import { and, Database, eq, isNull, sql } from "./drizzle"
+import {
+ BillingTable,
+ CouponTable,
+ CouponType,
+ LiteTable,
+ PaymentTable,
+ SubscriptionTable,
+ UsageTable,
+} from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
@@ -147,6 +155,37 @@ export namespace Billing {
return amountInMicroCents
}
+ export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
+ const coupon = await Database.use((tx) =>
+ tx
+ .select()
+ .from(CouponTable)
+ .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
+ .then((rows) => rows[0]),
+ )
+ if (!coupon) throw new Error("Invalid coupon code")
+ if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
+
+ if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
+
+ await Database.use((tx) =>
+ tx
+ .update(CouponTable)
+ .set({ timeRedeemed: sql`now()` })
+ .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
+ )
+ }
+
+ export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => {
+ return await Database.use((tx) =>
+ tx
+ .select()
+ .from(CouponTable)
+ .where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed)))
+ .then((rows) => rows.length > 0),
+ )
+ }
+
export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) =>
tx
@@ -245,16 +284,19 @@ export namespace Billing {
const user = Actor.assert("user")
const { successUrl, cancelUrl, method } = input
- const email = await User.getAuthEmail(user.properties.userID)
+ const email = (await User.getAuthEmail(user.properties.userID))!
const billing = await Billing.get()
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
+ const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH"))
+ ? LiteData.firstMonth100Coupon
+ : LiteData.firstMonth50Coupon
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
- discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
+ discounts: [{ coupon }],
...(billing.customerID
? {
customer: billing.customerID,
@@ -264,7 +306,7 @@ export namespace Billing {
},
}
: {
- customer_email: email!,
+ customer_email: email,
}),
...(() => {
if (method === "alipay") {
@@ -312,6 +354,8 @@ export namespace Billing {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
+ userEmail: email,
+ coupon,
type: "lite",
},
},
diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts
index 3343192c1..c04977664 100644
--- a/packages/console/core/src/lite.ts
+++ b/packages/console/core/src/lite.ts
@@ -11,11 +11,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
- export const firstMonthCoupon = fn(z.string(), (email) => {
- const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
- return invitees.includes(email)
- ? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
- : Resource.ZEN_LITE_PRICE.firstMonth50Coupon
- })
+ export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
+ export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
export const planName = fn(z.void(), () => "lite")
}
diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts
index 3d614d303..6281382d6 100644
--- a/packages/console/core/src/model.ts
+++ b/packages/console/core/src/model.ts
@@ -34,6 +34,8 @@ export namespace ZenData {
z.object({
id: z.string(),
model: z.string(),
+ priority: z.number().optional(),
+ tpmLimit: z.number().optional(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
@@ -123,10 +125,16 @@ export namespace ZenData {
),
models: (() => {
const normalize = (model: z.infer<typeof ModelSchema>) => {
- const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
+ const providers = model.providers.map((p) => ({
+ ...p,
+ priority: p.priority ?? Infinity,
+ weight: p.weight ?? 1,
+ }))
+ const composite = providers.find((p) => compositeProviders[p.id].length > 1)
if (!composite)
return {
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
+ providers,
}
const weightMulti = compositeProviders[composite.id].length
@@ -137,17 +145,16 @@ export namespace ZenData {
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
return [model.trialProvider]
})(),
- providers: model.providers.flatMap((p) =>
+ providers: providers.flatMap((p) =>
p.id === composite.id
? compositeProviders[p.id].map((sub) => ({
...p,
id: sub.id,
- weight: p.weight ?? 1,
}))
: [
{
...p,
- weight: (p.weight ?? 1) * weightMulti,
+ weight: p.weight * weightMulti,
},
],
),
diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts
index b06ca8966..f8dcbd2b1 100644
--- a/packages/console/core/src/schema/billing.sql.ts
+++ b/packages/console/core/src/schema/billing.sql.ts
@@ -1,4 +1,15 @@
-import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
+import {
+ bigint,
+ boolean,
+ index,
+ int,
+ json,
+ mysqlEnum,
+ mysqlTable,
+ primaryKey,
+ uniqueIndex,
+ varchar,
+} from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
@@ -121,3 +132,14 @@ export const UsageTable = mysqlTable(
},
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
)
+
+export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const
+export const CouponTable = mysqlTable(
+ "coupon",
+ {
+ email: varchar("email", { length: 255 }),
+ type: mysqlEnum("type", CouponType).notNull(),
+ timeRedeemed: utc("time_redeemed"),
+ },
+ (table) => [primaryKey({ columns: [table.email, table.type] })],
+)
diff --git a/packages/console/core/src/schema/ip.sql.ts b/packages/console/core/src/schema/ip.sql.ts
index a840a78c1..830842e64 100644
--- a/packages/console/core/src/schema/ip.sql.ts
+++ b/packages/console/core/src/schema/ip.sql.ts
@@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
},
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)
+
+export const ModelRateLimitTable = mysqlTable(
+ "model_rate_limit",
+ {
+ key: varchar("key", { length: 255 }).notNull(),
+ interval: varchar("interval", { length: 40 }).notNull(),
+ count: int("count").notNull(),
+ },
+ (table) => [primaryKey({ columns: [table.key, table.interval] })],
+)
diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts
index b77ee3c5b..bfba1b8f2 100644
--- a/packages/console/core/sst-env.d.ts
+++ b/packages/console/core/sst-env.d.ts
@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
- "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index 898c540ba..6fde7612d 100644
--- a/packages/console/function/package.json
+++ b/packages/console/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
- "version": "1.4.7",
+ "version": "1.14.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts
index b77ee3c5b..bfba1b8f2 100644
--- a/packages/console/function/sst-env.d.ts
+++ b/packages/console/function/sst-env.d.ts
@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
- "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 46ff28b7d..d45a84936 100644
--- a/packages/console/mail/package.json
+++ b/packages/console/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
- "version": "1.4.7",
+ "version": "1.14.18",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts
index b77ee3c5b..bfba1b8f2 100644
--- a/packages/console/resource/sst-env.d.ts
+++ b/packages/console/resource/sst-env.d.ts
@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
- "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index e1f69b5b2..01c6e84f3 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -30,6 +30,7 @@
"electron-store": "^10",
"electron-updater": "^6",
"electron-window-state": "^5.0.3",
+ "drizzle-orm": "catalog:",
"marked": "^15"
},
"devDependencies": {
@@ -45,7 +46,7 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
- "electron": "40.4.1",
+ "electron": "41.2.1",
"electron-builder": "^26",
"electron-vite": "^5",
"solid-js": "catalog:",
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 946e01e32..6c4e6d5ca 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -28,8 +28,10 @@ const APP_IDS: Record<string, string> = {
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
+const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
-app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
+app.setAppUserModelId(appId)
+app.setPath("userData", join(app.getPath("appData"), appId))
const { autoUpdater } = pkg
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
@@ -41,6 +43,7 @@ import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
+import { drizzle } from "drizzle-orm/node-sqlite/driver"
import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
@@ -137,15 +140,6 @@ async function initialize() {
const url = `http://${hostname}:${port}`
const password = randomUUID()
- logger.log("spawning sidecar", { url })
- const { listener, health } = await spawnLocalServer(hostname, port, password)
- server = listener
- serverReady.resolve({
- url,
- username: "opencode",
- password,
- })
-
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
@@ -157,9 +151,31 @@ async function initialize() {
})
if (needsMigration) {
+ const { Database, JsonMigration } = await import("virtual:opencode-server")
+ await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
+ progress: (event: { current: number; total: number }) => {
+ const percent = Math.round(event.current / event.total) * 100
+ initEmitter.emit("sqlite", { type: "InProgress", value: percent })
+ },
+ })
+ initEmitter.emit("sqlite", { type: "Done" })
+
+ sqliteDone?.resolve()
+ }
+
+ if (needsMigration) {
await sqliteDone?.promise
}
+ logger.log("spawning sidecar", { url })
+ const { listener, health } = await spawnLocalServer(hostname, port, password)
+ server = listener
+ serverReady.resolve({
+ url,
+ username: "opencode",
+ password,
+ })
+
await Promise.race([
health.wait,
delay(30_000).then(() => {
diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts
index bad1349ee..70e3dc9c7 100644
--- a/packages/desktop-electron/src/main/migrate.ts
+++ b/packages/desktop-electron/src/main/migrate.ts
@@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { CHANNEL } from "./constants"
-import { getStore, store } from "./store"
+import { getStore } from "./store"
const TAURI_MIGRATED_KEY = "tauriMigrated"
@@ -67,7 +67,7 @@ function migrateFile(datPath: string, filename: string) {
}
export function migrate() {
- if (store.get(TAURI_MIGRATED_KEY)) {
+ if (getStore().get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}
@@ -77,7 +77,7 @@ export function migrate() {
if (!existsSync(dir)) {
log.log("tauri migration: no tauri data directory found, nothing to migrate")
- store.set(TAURI_MIGRATED_KEY, true)
+ getStore().set(TAURI_MIGRATED_KEY, true)
return
}
@@ -87,5 +87,5 @@ export function migrate() {
}
log.log("tauri migration: complete")
- store.set(TAURI_MIGRATED_KEY, true)
+ getStore().set(TAURI_MIGRATED_KEY, true)
}
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index 5a6050013..55dfdf6e9 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -1,33 +1,33 @@
import { app } from "electron"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
-import { store } from "./store"
+import { getStore } from "./store"
export type WslConfig = { enabled: boolean }
export type HealthCheck = { wait: Promise<void> }
export function getDefaultServerUrl(): string | null {
- const value = store.get(DEFAULT_SERVER_URL_KEY)
+ const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
- store.set(DEFAULT_SERVER_URL_KEY, url)
+ getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
- store.delete(DEFAULT_SERVER_URL_KEY)
+ getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function getWslConfig(): WslConfig {
- const value = store.get(WSL_ENABLED_KEY)
+ const value = getStore().get(WSL_ENABLED_KEY)
return { enabled: typeof value === "boolean" ? value : false }
}
export function setWslConfig(config: WslConfig) {
- store.set(WSL_ENABLED_KEY, config.enabled)
+ getStore().set(WSL_ENABLED_KEY, config.enabled)
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts
index 709e820e2..61f0c0a49 100644
--- a/packages/desktop-electron/src/main/store.ts
+++ b/packages/desktop-electron/src/main/store.ts
@@ -4,6 +4,10 @@ import { SETTINGS_STORE } from "./constants"
const cache = new Map<string, Store>()
+// We cannot instantiate the electron-store at module load time because
+// module import hoisting causes this to run before app.setPath("userData", ...)
+// in index.ts has executed, which would result in files being written to the default directory
+// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
export function getStore(name = SETTINGS_STORE) {
const cached = cache.get(name)
if (cached) return cached
@@ -11,5 +15,3 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
-
-export const store = getStore(SETTINGS_STORE)
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index d8eea4ea3..d3642523a 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index 12a72e647..885d52b9b 100644
--- a/packages/enterprise/package.json
+++ b/packages/enterprise/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
- "version": "1.4.7",
+ "version": "1.14.18",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts
index b77ee3c5b..bfba1b8f2 100644
--- a/packages/enterprise/sst-env.d.ts
+++ b/packages/enterprise/sst-env.d.ts
@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
- "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index d164534cf..7ae4694fb 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
-version = "1.4.7"
+version = "1.14.18"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-darwin-arm64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-darwin-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-linux-x64.tar.gz"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-windows-x64.zip"
+archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.18/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
diff --git a/packages/function/package.json b/packages/function/package.json
index 36a9ddc32..a9a935639 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
- "version": "1.4.7",
+ "version": "1.14.18",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index b77ee3c5b..bfba1b8f2 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -142,10 +142,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
- "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 1dabd91b8..42f30b45e 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.4.7",
+ "version": "1.14.18",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -79,15 +79,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
- "@ai-sdk/amazon-bedrock": "4.0.94",
- "@ai-sdk/anthropic": "3.0.70",
+ "@ai-sdk/amazon-bedrock": "4.0.96",
+ "@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
- "@ai-sdk/gateway": "3.0.102",
+ "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
- "@ai-sdk/google-vertex": "4.0.111",
+ "@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -122,8 +122,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
- "@opentui/core": "0.1.99",
- "@opentui/solid": "0.1.99",
+ "@opentui/core": "0.1.101",
+ "@opentui/solid": "0.1.101",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -143,7 +143,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
- "gitlab-ai-provider": "6.4.2",
+ "gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -161,7 +161,6 @@
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
- "ripgrep": "0.3.1",
"semver": "^7.6.3",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts
index 5aa14d52c..85e1e105f 100755
--- a/packages/opencode/script/build.ts
+++ b/packages/opencode/script/build.ts
@@ -187,7 +187,6 @@ for (const item of targets) {
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/cmd/tui/worker.ts"
- const rgPath = "./src/file/ripgrep.worker.ts"
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
@@ -212,19 +211,12 @@ for (const item of targets) {
windows: {},
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
- entrypoints: [
- "./src/index.ts",
- parserWorker,
- workerPath,
- rgPath,
- ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []),
- ],
+ entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
- OPENCODE_RIPGREP_WORKER_PATH: rgPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
},
diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts
index 9c4b8f187..7557c475f 100755
--- a/packages/opencode/script/publish.ts
+++ b/packages/opencode/script/publish.ts
@@ -7,6 +7,22 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
+async function published(name: string, version: string) {
+ return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
+}
+
+async function publish(dir: string, name: string, version: string) {
+ // GitHub artifact downloads can drop the executable bit, and Docker uses the
+ // unpacked dist binaries directly rather than the published tarball.
+ if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
+ if (await published(name, version)) {
+ console.log(`already published ${name}@${version}`)
+ return
+ }
+ await $`bun pm pack`.cwd(dir)
+ await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
+}
+
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const pkg = await Bun.file(`./dist/${filepath}`).json()
@@ -40,14 +56,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
)
const tasks = Object.entries(binaries).map(async ([name]) => {
- if (process.platform !== "win32") {
- await $`chmod -R 755 .`.cwd(`./dist/${name}`)
- }
- await $`bun pm pack`.cwd(`./dist/${name}`)
- await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`)
+ await publish(`./dist/${name}`, name, binaries[name])
})
await Promise.all(tasks)
-await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}`
+await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
const image = "ghcr.io/anomalyco/opencode"
const platforms = "linux/amd64,linux/arm64"
@@ -104,6 +116,7 @@ if (!Script.preview) {
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
+ if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
@@ -176,6 +189,8 @@ if (!Script.preview) {
await $`git clone ${tap} ./dist/homebrew-tap`
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
await $`cd ./dist/homebrew-tap && git add opencode.rb`
- await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
- await $`cd ./dist/homebrew-tap && git push`
+ if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
+ await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
+ await $`cd ./dist/homebrew-tap && git push`
+ }
}
diff --git a/packages/opencode/specs/effect/facades.md b/packages/opencode/specs/effect/facades.md
index e2d9d3d8a..8bf7d97ba 100644
--- a/packages/opencode/specs/effect/facades.md
+++ b/packages/opencode/specs/effect/facades.md
@@ -1,12 +1,13 @@
# Facade removal checklist
-Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
+Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
-As of 2026-04-13, latest `origin/dev`:
+Current status on this branch:
-- `src/` still has 15 `makeRuntime(...)` call sites.
-- 13 of those are still in scope for facade removal.
-- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
+- `src/` has 5 `makeRuntime(...)` call sites total.
+- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
+- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
+- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -15,8 +16,9 @@ Recent progress:
## Priority hotspots
-- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
-- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
+- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
+- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
+- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
-## Next batch
+## Remaining work
-Recommended next five, in order:
+Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
-1. `src/permission/index.ts`
-2. `src/agent/agent.ts`
-3. `src/session/summary.ts`
-4. `src/session/revert.ts`
-5. `src/mcp/auth.ts`
-
-Why this batch:
-
-- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
-- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
-- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
-
-After that batch, the expected follow-up is the main session cluster:
-
-1. `src/session/index.ts`
-2. `src/session/prompt.ts`
-3. `src/session/compaction.ts`
+1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
+2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
+3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
-- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
-- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
-- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
-- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
-- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
-- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
-- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
-- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
-- [x] `src/file/index.ts` (`File`) - facades removed and merged.
-- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
-- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
-- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
-- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
-- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
-- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
-- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
-- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
-- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
-- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
-- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
-- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
-- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
-- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
+- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
+- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
+- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
+- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
+- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
+- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
+- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
+- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
+- [x] `src/file/index.ts` (`File`) - facades removed and merged
+- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
+- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
+- [x] `src/config/config.ts` (`Config`) - facades removed and merged
+- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
+- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
+- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
+- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
+- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
+- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
## Excluded `makeRuntime(...)` sites
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index 71b50250e..93ef81a32 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
### Mixed handler styles
-Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
+Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
### 1. Finish the prerequisites first
-- continue route-handler effectification in `server/instance/*.ts`
+- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
-- `server/instance/question.ts`
-- `server/instance/provider.ts`
-- `server/instance/permission.ts`
+- `server/routes/instance/question.ts`
+- `server/routes/instance/provider.ts`
+- `server/routes/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
@@ -155,9 +155,9 @@ This gives:
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
+1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
+2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
+3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
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
+- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
+- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
+
+### Schema.Class vs Schema.Struct
+
+The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
+
+**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
+
+```ts
+export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
+ static readonly zod = zod(this)
+}
+```
+
+**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
+
+```ts
+export const Info = Schema.Struct({ ... }).pipe(
+ withStatics((s) => ({ zod: zod(s) })),
+)
+export type Info = Schema.Schema.Type<typeof Info>
+```
+
+When to use each:
+
+- Use **Schema.Class** when:
+ - the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
+ - the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
+- Use **Schema.Struct** when:
+ - the type is only used as a nested field inside another named schema
+ - the original Zod was anonymous and promoting it would bloat SDK types with no import value
+
+Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
+
+Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
+
+```ts
+export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
+```
Temporary exception:
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
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.
+6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
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.
@@ -250,20 +286,20 @@ Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
-- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
-- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
+- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
+- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
-- `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
+- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
+- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
+- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `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
+- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
+- tests should verify the bridged routes through the normal server surface
## Example migration shape
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
-### 3. Standalone server
+### 3. Bridged server
-- 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
+- the Effect HTTP layer is composed in `httpapi/server.ts`
+- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
+- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
+- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
-- call the experimental endpoints
+- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
-The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
+The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
-- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
+- the bridged `HttpApi` layer 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
+- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
-- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
+- the bridged `HttpApi` layer 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
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
The first slice is successful if:
-- the standalone Effect server starts and serves the endpoints independently of the Hono server
+- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- 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
@@ -365,17 +401,16 @@ Current instance route inventory:
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`
+- `provider` - `bridged`
+ endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
+- `config` - `bridged` (partial)
+ bridged endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
-- `project` - `later`
- best small reads: `GET /project`, `GET /project/current`
+- `project` - `bridged` (partial)
+ bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
-- `workspace` - `later`
+- `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
-Recommended near-term sequence after the first spike:
+Recommended near-term sequence:
-1. `provider` auth read endpoint
-2. `config` providers read endpoint
-3. `project` read endpoints
-4. `workspace` read endpoints
+1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
+2. `config` full read endpoint (`GET /config`)
+3. `file` JSON read endpoints
+4. `mcp` JSON read endpoints
## Checklist
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [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
+- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
+- [x] port `config` providers read endpoint
+- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
+- [ ] port `workspace` read endpoints
+- [ ] port `GET /config` full read endpoint
+- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md
index 6c160a947..7d0d7eb13 100644
--- a/packages/opencode/specs/effect/instance-context.md
+++ b/packages/opencode/specs/effect/instance-context.md
@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
-Current total: `54` files in `packages/opencode/src`.
+Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
@@ -177,13 +177,13 @@ Migration rule:
These are the current request-entry seams that still create or consume instance context through the legacy helper.
-- `src/server/instance/middleware.ts`
-- `src/server/instance/index.ts`
-- `src/server/instance/project.ts`
-- `src/server/instance/workspace.ts`
-- `src/server/instance/file.ts`
-- `src/server/instance/experimental.ts`
-- `src/server/instance/global.ts`
+- `src/server/routes/instance/middleware.ts`
+- `src/server/routes/instance/index.ts`
+- `src/server/routes/instance/project.ts`
+- `src/server/routes/control/workspace.ts`
+- `src/server/routes/instance/file.ts`
+- `src/server/routes/instance/experimental.ts`
+- `src/server/routes/global.ts`
Migration rule:
@@ -239,7 +239,7 @@ Migration rule:
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
-- `src/config/tui-migrate.ts`
+- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
-- `src/session/index.ts`
+- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
diff --git a/packages/opencode/specs/effect/loose-ends.md b/packages/opencode/specs/effect/loose-ends.md
index a2fed492b..4e7ada7ff 100644
--- a/packages/opencode/specs/effect/loose-ends.md
+++ b/packages/opencode/specs/effect/loose-ends.md
@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
## Config / TUI
-- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
+- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
-- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
+- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
-- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
+- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
## ConfigPaths
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
-- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
-- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
+- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
+- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
-- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
-- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
-- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
+- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes
diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md
index 105a82290..947eef5a1 100644
--- a/packages/opencode/specs/effect/migration.md
+++ b/packages/opencode/specs/effect/migration.md
@@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
-- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
+- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
## Service shape
-Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
+Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
```ts
-export namespace Foo {
- export interface Interface {
- readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
- }
-
- export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- // For instance-scoped services:
- const state = yield* InstanceState.make<State>(
- Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
- )
+export interface Interface {
+ readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
+}
- const get = Effect.fn("Foo.get")(function* (id: FooID) {
- const s = yield* InstanceState.get(state)
- // ...
- })
+export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
- return Service.of({ get })
- }),
- )
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
+ )
- // Optional: wire dependencies
- export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
+ const get = Effect.fn("Foo.get")(function* (id: FooID) {
+ const s = yield* InstanceState.get(state)
+ // ...
+ })
- // Per-service runtime (inside the namespace)
- const { runPromise } = makeRuntime(Service, defaultLayer)
+ return Service.of({ get })
+ }),
+)
- // Async facade functions
- export async function get(id: FooID) {
- return runPromise((svc) => svc.get(id))
- }
-}
+export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
+
+export * as Foo from "."
```
Rules:
-- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
-- `runPromise` goes inside the namespace (not exported unless tests need it)
-- Facade functions are plain `async function` — no `fn()` wrappers
-- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
-- No `Layer.fresh` — InstanceState handles per-directory isolation
+- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
+- Use `Effect.fn("Foo.method")` for Effect methods
+- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
+- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
+- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## Schema → Zod interop
@@ -195,7 +185,6 @@ This checklist is only about the service shape migration. Many of these services
- [x] `Config` — `config/config.ts`
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
- [x] `File` — `file/index.ts`
-- [x] `FileTime` — `file/time.ts`
- [x] `FileWatcher` — `file/watcher.ts`
- [x] `Format` — `format/index.ts`
- [x] `Installation` — `installation/index.ts`
@@ -267,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
-This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
+This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
@@ -298,12 +287,11 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
-- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
-- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
+- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
+- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
-- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
-- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
+- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
diff --git a/packages/opencode/specs/effect/routes.md b/packages/opencode/specs/effect/routes.md
index f6a61d234..3bf7e1b55 100644
--- a/packages/opencode/specs/effect/routes.md
+++ b/packages/opencode/specs/effect/routes.md
@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
## Current route files
-Current instance route files live under `src/server/instance`, not `server/routes`.
+Current instance route files live under `src/server/routes/instance`.
-The main migration targets are:
+Files that are already mostly on the intended service-yielding shape:
-- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
-- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
-- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
-- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
-- [ ] `server/instance/pty.ts` — still calls Pty facades directly
-- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
+- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
+- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
+- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
+- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
+- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service`
-Additional route files that still participate in the migration:
+Files still worth tracking here:
-- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
-- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
-- [ ] `server/instance/mcp.ts` — MCP facade-heavy
-- [ ] `server/instance/permission.ts` — Permission
-- [ ] `server/instance/workspace.ts` — Workspace
-- [ ] `server/instance/tui.ts` — Bus and Session
-- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
+- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
+- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
+- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
+- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
+- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
+- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
## Notes
-- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
-- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
+- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
+- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.
diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md
index eed69e52b..72ee10350 100644
--- a/packages/opencode/specs/effect/schema.md
+++ b/packages/opencode/specs/effect/schema.md
@@ -1,12 +1,19 @@
# Schema migration
-Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
+Practical reference for migrating data types in `packages/opencode` from
+Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
-Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
+Use Effect Schema as the source of truth for domain models, IDs, inputs,
+outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
+compatibility boundaries by exposing a `.zod` static derived from the Effect
+schema via `@/util/effect-zod`.
-Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
+The long-term driver is `specs/effect/http-api.md` — once the HTTP server
+moves to `@effect/platform`, every Schema-first DTO can flow through
+`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
+`effect-zod` walker plus every `.zod` static can be deleted.
## Preferred shapes
@@ -24,17 +31,14 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
}
```
-If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
+If the class cannot reference itself cleanly during initialization, use the
+two-step `withStatics` pattern:
```ts
-const _Info = Schema.Struct({
+export const Info = Schema.Struct({
id: FooID,
name: Schema.String,
-})
-
-export const Info = Object.assign(_Info, {
- zod: zod(_Info),
-})
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
```
### Errors
@@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
### IDs and branded leaf types
-Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
+Keep branded/schema-backed IDs as Effect schemas and expose
+`static readonly zod` for compatibility when callers still expect Zod.
+
+### Refinements
+
+Reuse named refinements instead of re-spelling `z.number().int().positive()`
+in every schema. The `effect-zod` walker translates the Effect versions into
+the corresponding zod methods, so JSON Schema output (`type: integer`,
+`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
+
+```ts
+const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
+const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
+const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
+```
+
+See `test/util/effect-zod.test.ts` for the full set of translated checks.
## Compatibility rule
-During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
+During migration, route validators, tool parameters, and any existing
+Zod-based boundary should consume the derived `.zod` schema instead of
+maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
-- new domain models should not start Zod-first unless there is a concrete boundary-specific need
+- new domain models should not start Zod-first unless there is a concrete
+ boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
-- the type is only used at an HTTP or tool boundary
+- the type is only used at an HTTP or tool boundary and is not reused elsewhere
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
-When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
+When this happens, prefer leaving a short note or TODO rather than silently
+creating a parallel schema source of truth.
+
+## Escape hatches
+
+The walker in `@/util/effect-zod` exposes three explicit escape hatches for
+cases the pure-Schema path cannot express. Each one stays in the codebase
+only as long as its upstream or local dependency requires it — inline
+comments document when each can be deleted.
+
+### `ZodOverride` annotation
+
+Replaces the entire derivation with a hand-crafted zod schema. Used when:
+
+- the target carries external `$ref` metadata (e.g.
+ `config/model-id.ts` points at `https://models.dev/...`)
+- the target is a zod-only schema that cannot yet be expressed as Schema
+ (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
+
+### `ZodPreprocess` annotation
+
+Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
+`config/permission.ts` to inject `__originalKeys` before parsing, because
+`Schema.StructWithRest` canonicalises output (known fields first, catchall
+after) and destroys the user's original property order — which permission
+rule precedence depends on.
+
+Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
+(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
+the `__originalKeys` hack can both be deleted.
+
+### Local `DeepMutable<T>` in `config/config.ts`
+
+`Schema.Struct` produces `readonly` types. Some consumer code (notably the
+`Config` service) mutates `Info` objects directly, so a readonly-stripping
+utility is needed when casting the derived zod schema's output type.
+
+`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
+`unknown` to `{}` in the fallback branch — a bug that affects any schema
+using `Schema.Record(String, Schema.Unknown)`.
+
+Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
+to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
+`Types.DeepMutable` used directly.
## Ordering
@@ -81,19 +147,179 @@ Migrate in this order:
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
-This keeps shared types canonical first and makes boundary updates mostly mechanical.
-
-## Checklist
-
-- [ ] Shared `schema.ts` leaf models are Effect Schema-first
-- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
-- [ ] Domain errors use `Schema.TaggedErrorClass`
-- [ ] Migrated types expose `.zod` for back compatibility
-- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
-- [ ] New domain models default to Effect Schema first
+This keeps shared types canonical first and makes boundary updates mostly
+mechanical.
+
+## Progress tracker
+
+### `src/config/` ✅ complete
+
+All of `packages/opencode/src/config/` has been migrated. Files that still
+import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
+type annotations — the `export const <Info|Spec>` values are all Effect
+Schema at source.
+
+- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
+- [x] server, layout
+- [x] keybinds
+- [x] permission#Info
+- [x] agent
+- [x] config.ts root
+
+### `src/*/schema.ts` leaf modules
+
+These are the highest-priority next targets. Each is a small, self-contained
+schema module with a clear domain.
+
+- [ ] `src/control-plane/schema.ts`
+- [ ] `src/permission/schema.ts`
+- [ ] `src/project/schema.ts`
+- [ ] `src/provider/schema.ts`
+- [ ] `src/pty/schema.ts`
+- [ ] `src/question/schema.ts`
+- [ ] `src/session/schema.ts`
+- [ ] `src/sync/schema.ts`
+- [ ] `src/tool/schema.ts`
+
+### Session domain
+
+Major cluster. Message + event types flow through the SSE API and every SDK
+output, so byte-identical SDK surface is critical.
+
+- [ ] `src/session/compaction.ts`
+- [ ] `src/session/message-v2.ts`
+- [ ] `src/session/message.ts`
+- [ ] `src/session/prompt.ts`
+- [ ] `src/session/revert.ts`
+- [ ] `src/session/session.ts`
+- [ ] `src/session/status.ts`
+- [ ] `src/session/summary.ts`
+- [ ] `src/session/todo.ts`
+
+### Provider domain
+
+- [ ] `src/provider/auth.ts`
+- [ ] `src/provider/models.ts`
+- [ ] `src/provider/provider.ts`
+
+### Tool schemas
+
+Each tool declares its parameters via a zod schema. Tools are consumed by
+both the in-process runtime and the AI SDK's tool-calling layer, so the
+emitted JSON Schema must stay byte-identical.
+
+- [ ] `src/tool/apply_patch.ts`
+- [ ] `src/tool/bash.ts`
+- [ ] `src/tool/codesearch.ts`
+- [ ] `src/tool/edit.ts`
+- [ ] `src/tool/glob.ts`
+- [ ] `src/tool/grep.ts`
+- [ ] `src/tool/invalid.ts`
+- [ ] `src/tool/lsp.ts`
+- [ ] `src/tool/multiedit.ts`
+- [ ] `src/tool/plan.ts`
+- [ ] `src/tool/question.ts`
+- [ ] `src/tool/read.ts`
+- [ ] `src/tool/registry.ts`
+- [ ] `src/tool/skill.ts`
+- [ ] `src/tool/task.ts`
+- [ ] `src/tool/todo.ts`
+- [ ] `src/tool/tool.ts`
+- [ ] `src/tool/webfetch.ts`
+- [ ] `src/tool/websearch.ts`
+- [ ] `src/tool/write.ts`
+
+### HTTP route boundaries
+
+Every file in `src/server/routes/` uses hono-openapi with zod validators for
+route inputs/outputs. Migrating these individually is the last step; most
+will switch to `.zod` derived from the Schema-migrated domain types above,
+which means touching them is largely mechanical once the domain side is
+done.
+
+- [ ] `src/server/error.ts`
+- [ ] `src/server/event.ts`
+- [ ] `src/server/projectors.ts`
+- [ ] `src/server/routes/control/index.ts`
+- [ ] `src/server/routes/control/workspace.ts`
+- [ ] `src/server/routes/global.ts`
+- [ ] `src/server/routes/instance/index.ts`
+- [ ] `src/server/routes/instance/config.ts`
+- [ ] `src/server/routes/instance/event.ts`
+- [ ] `src/server/routes/instance/experimental.ts`
+- [ ] `src/server/routes/instance/file.ts`
+- [ ] `src/server/routes/instance/mcp.ts`
+- [ ] `src/server/routes/instance/permission.ts`
+- [ ] `src/server/routes/instance/project.ts`
+- [ ] `src/server/routes/instance/provider.ts`
+- [ ] `src/server/routes/instance/pty.ts`
+- [ ] `src/server/routes/instance/question.ts`
+- [ ] `src/server/routes/instance/session.ts`
+- [ ] `src/server/routes/instance/sync.ts`
+- [ ] `src/server/routes/instance/tui.ts`
+
+The bigger prize for this group is the `@effect/platform` HTTP migration
+described in `specs/effect/http-api.md`. Once that lands, every one of
+these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
+so the Schema-first domain types become a prerequisite rather than a
+sibling task.
+
+### Everything else
+
+Small / shared / control-plane / CLI. Mostly independent; can be done
+piecewise.
+
+- [ ] `src/acp/agent.ts`
+- [ ] `src/agent/agent.ts`
+- [ ] `src/bus/bus-event.ts`
+- [ ] `src/bus/index.ts`
+- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
+- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
+- [ ] `src/cli/cmd/tui/config/tui.ts`
+- [ ] `src/cli/cmd/tui/event.ts`
+- [ ] `src/cli/ui.ts`
+- [ ] `src/command/index.ts`
+- [ ] `src/control-plane/adaptors/worktree.ts`
+- [ ] `src/control-plane/types.ts`
+- [ ] `src/control-plane/workspace.ts`
+- [ ] `src/file/index.ts`
+- [ ] `src/file/ripgrep.ts`
+- [ ] `src/file/watcher.ts`
+- [ ] `src/format/index.ts`
+- [ ] `src/id/id.ts`
+- [ ] `src/ide/index.ts`
+- [ ] `src/installation/index.ts`
+- [ ] `src/lsp/client.ts`
+- [ ] `src/lsp/lsp.ts`
+- [ ] `src/mcp/auth.ts`
+- [ ] `src/patch/index.ts`
+- [ ] `src/plugin/github-copilot/models.ts`
+- [ ] `src/project/project.ts`
+- [ ] `src/project/vcs.ts`
+- [ ] `src/pty/index.ts`
+- [ ] `src/skill/index.ts`
+- [ ] `src/snapshot/index.ts`
+- [ ] `src/storage/db.ts`
+- [ ] `src/storage/storage.ts`
+- [ ] `src/sync/index.ts`
+- [ ] `src/util/fn.ts`
+- [ ] `src/util/log.ts`
+- [ ] `src/util/update-schema.ts`
+- [ ] `src/worktree/index.ts`
+
+### Do-not-migrate
+
+- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
+ (it's what emits zod from Schema). Goes away only when the `.zod`
+ compatibility layer is no longer needed anywhere.
## Notes
-- Use `@/util/effect-zod` for all Schema -> Zod conversion.
-- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
-- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
+- Use `@/util/effect-zod` for all Schema → Zod conversion.
+- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
+ Effect definitions for the same domain type.
+- Keep the migration incremental. Converting the domain model first is more
+ valuable than converting every boundary in the same change.
+- Every migrated file should leave the generated SDK output (`packages/sdk/
+openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
+ unless the change is deliberately user-visible.
diff --git a/packages/opencode/specs/effect/server-package.md b/packages/opencode/specs/effect/server-package.md
index 10be7b9ae..06e89c18d 100644
--- a/packages/opencode/specs/effect/server-package.md
+++ b/packages/opencode/specs/effect/server-package.md
@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
-- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
+- there is no `packages/server` workspace yet on this branch
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
-- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
-- that experimental slice is mounted under `/experimental/httpapi/question`
-- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
+- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
+- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
+- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
This means the package split should start from an extraction path, not from greenfield package ownership.
@@ -209,17 +209,19 @@ Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
-- `src/server/instance/index.ts`
+- `src/server/routes/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
-Current experimental `HttpApi` slice:
+Current bridged `HttpApi` slices:
-- `src/server/instance/httpapi/question.ts`
-- `src/server/instance/httpapi/index.ts`
-- `src/server/instance/experimental.ts`
-- `test/server/question-httpapi.test.ts`
+- `src/server/routes/instance/httpapi/question.ts`
+- `src/server/routes/instance/httpapi/permission.ts`
+- `src/server/routes/instance/httpapi/provider.ts`
+- `src/server/routes/instance/httpapi/config.ts`
+- `src/server/routes/instance/httpapi/project.ts`
+- `src/server/routes/instance/httpapi/server.ts`
Current OpenAPI flow:
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
-- `src/server/instance/*.ts`
+- `src/server/routes/**/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
## First vertical slice
-The first slice for the package split is the existing experimental `question` group.
+The first slice for the package split is still the existing `question` `HttpApi` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
-- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
@@ -357,7 +358,7 @@ Done means:
Scope:
-- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
+- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
-- `/experimental/httpapi/question`
-- `/experimental/httpapi/question/doc`
+- `/question`
+- `/question/:requestID/reply`
+- `/question/:requestID/reject`
Rules:
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
-- the relevant route test, especially `test/server/question-httpapi.test.ts`
+- the relevant server / route coverage for the migrated slice
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md
index e97e0d23e..7b4783170 100644
--- a/packages/opencode/specs/effect/tools.md
+++ b/packages/opencode/specs/effect/tools.md
@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
## Exported tools
-These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
+These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
-- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
Notes:
-- `batch.ts` is no longer a current tool file and should not be tracked here.
+- There is no current `ls.ts` tool file on this branch.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
@@ -73,7 +72,7 @@ Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
-- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
+- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
-- `ls.ts`
- `multiedit.ts`
- `edit.ts`
diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts
index fad4514c8..0531d537c 100644
--- a/packages/opencode/src/cli/cmd/generate.ts
+++ b/packages/opencode/src/cli/cmd/generate.ts
@@ -25,7 +25,19 @@ export const GenerateCommand = {
]
}
}
- const json = JSON.stringify(specs, null, 2)
+ const raw = JSON.stringify(specs, null, 2)
+
+ // Format through prettier so output is byte-identical to committed file
+ // regardless of whether ./script/format.ts runs afterward.
+ const prettier = await import("prettier")
+ const babel = await import("prettier/plugins/babel")
+ const estree = await import("prettier/plugins/estree")
+ const format = prettier.format ?? prettier.default?.format
+ const json = await format(raw, {
+ parser: "json",
+ plugins: [babel.default ?? babel, estree.default ?? estree],
+ printWidth: 120,
+ })
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 8255c007d..5da2740cc 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -1,7 +1,6 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
-import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@@ -120,12 +119,6 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
- const mode = await Terminal.getTerminalBackgroundColor()
-
- // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
- // the original console mode which re-enables ENABLE_PROCESSED_INPUT.
- win32DisableProcessedInput()
-
const onExit = async () => {
unguard?.()
resolve()
@@ -136,6 +129,7 @@ export function tui(input: {
}
const renderer = await createCliRenderer(rendererConfig(input.config))
+ const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
return (
@@ -148,7 +142,16 @@ export function tui(input: {
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
- <RouteProvider>
+ <RouteProvider
+ initialRoute={
+ input.args.continue
+ ? {
+ type: "session",
+ sessionID: "dummy",
+ }
+ : undefined
+ }
+ >
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
@@ -333,7 +336,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
local.model.set({ providerID, modelID }, { recent: true })
}
- // Handle --session without --fork immediately (fork is handled in createEffect below)
if (args.sessionID && !args.fork) {
route.navigate({
type: "session",
@@ -420,12 +422,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
aliases: ["clear"],
},
onSelect: () => {
- const current = promptRef.current
- // Don't require focus - if there's any text, preserve it
- const currentPrompt = current?.current?.input ? current.current : undefined
route.navigate({
type: "home",
- initialPrompt: currentPrompt,
})
dialog.clear()
},
@@ -602,7 +600,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
- title: "Toggle theme mode",
+ title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
index f42ba15ec..49bf42c63 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx
@@ -63,6 +63,7 @@ function init() {
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
+ if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
index 60ef6087b..32342e772 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
@@ -139,15 +139,10 @@ export function DialogSessionList() {
{desc}{" "}
<span
style={{
- fg:
- workspaceStatus === "error"
- ? theme.error
- : workspaceStatus === "disconnected"
- ? theme.textMuted
- : theme.success,
+ fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
- ■
+ ●
</span>
</>
)
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
index ad5cd4578..a16c98a9f 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
total: result.data.total,
})
- await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
+ input.project.workspace.set(input.workspaceID)
+
+ try {
+ await input.sync.bootstrap({ fatal: false })
+ } catch (e) {}
+
+ await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -229,6 +235,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
+ toast.show({
+ message: "Creating workspace failed",
+ variant: "error",
+ })
log.error("workspace create request failed", {
type,
error: errorData(err),
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx
new file mode 100644
index 000000000..7a2179853
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx
@@ -0,0 +1,81 @@
+import { TextAttributes } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
+import { createStore } from "solid-js/store"
+import { For } from "solid-js"
+import { useTheme } from "../context/theme"
+import { useDialog } from "../ui/dialog"
+
+export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
+ const dialog = useDialog()
+ const { theme } = useTheme()
+ const [store, setStore] = createStore({
+ active: "restore" as "cancel" | "restore",
+ })
+
+ const options = ["cancel", "restore"] as const
+
+ async function confirm() {
+ if (store.active === "cancel") {
+ dialog.clear()
+ return
+ }
+ const result = await props.onRestore?.()
+ if (result === false) return
+ }
+
+ useKeyboard((evt) => {
+ if (evt.name === "return") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ void confirm()
+ return
+ }
+ if (evt.name === "left") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ setStore("active", "cancel")
+ return
+ }
+ if (evt.name === "right") {
+ evt.preventDefault()
+ evt.stopPropagation()
+ setStore("active", "restore")
+ }
+ })
+
+ return (
+ <box paddingLeft={2} paddingRight={2} gap={1}>
+ <box flexDirection="row" justifyContent="space-between">
+ <text attributes={TextAttributes.BOLD} fg={theme.text}>
+ Workspace Unavailable
+ </text>
+ <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
+ esc
+ </text>
+ </box>
+ <text fg={theme.textMuted} wrapMode="word">
+ This session is attached to a workspace that is no longer available.
+ </text>
+ <text fg={theme.textMuted} wrapMode="word">
+ Would you like to restore this session into a new workspace?
+ </text>
+ <box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
+ <For each={options}>
+ {(item) => (
+ <box
+ paddingLeft={2}
+ paddingRight={2}
+ backgroundColor={item === store.active ? theme.primary : undefined}
+ onMouseUp={() => {
+ setStore("active", item)
+ void confirm()
+ }}
+ >
+ <text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
+ </box>
+ )}
+ </For>
+ </box>
+ </box>
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx
index 17368ddad..bee104a35 100644
--- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx
@@ -3,13 +3,60 @@ import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "sol
import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
import { go, logo } from "@/cli/logo"
-import { shimmerConfig, type ShimmerConfig } from "./shimmer-config"
export type LogoShape = {
left: string[]
right: string[]
}
+type ShimmerConfig = {
+ period: number
+ rings: number
+ sweepFraction: number
+ coreWidth: number
+ coreAmp: number
+ softWidth: number
+ softAmp: number
+ tail: number
+ tailAmp: number
+ haloWidth: number
+ haloOffset: number
+ haloAmp: number
+ breathBase: number
+ noise: number
+ ambientAmp: number
+ ambientCenter: number
+ ambientWidth: number
+ shadowMix: number
+ primaryMix: number
+ originX: number
+ originY: number
+}
+
+const shimmerConfig: ShimmerConfig = {
+ period: 4600,
+ rings: 2,
+ sweepFraction: 1,
+ coreWidth: 1.2,
+ coreAmp: 1.9,
+ softWidth: 10,
+ softAmp: 1.6,
+ tail: 5,
+ tailAmp: 0.64,
+ haloWidth: 4.3,
+ haloOffset: 0.6,
+ haloAmp: 0.16,
+ breathBase: 0.04,
+ noise: 0.1,
+ ambientAmp: 0.36,
+ ambientCenter: 0.5,
+ ambientWidth: 0.34,
+ shadowMix: 0.1,
+ primaryMix: 0.3,
+ originX: 4.5,
+ originY: 13.5,
+}
+
// Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow)
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 82c4a7222..2e08e66a4 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -1,18 +1,19 @@
-import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
+import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local"
-import { useTheme } from "@tui/context/theme"
+import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
+import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
-import { createStore, produce } from "solid-js/store"
+import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
@@ -35,8 +36,11 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
+import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
+import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
+import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
export type PromptProps = {
@@ -75,6 +79,12 @@ function randomIndex(count: number) {
return Math.floor(Math.random() * count)
}
+function fadeColor(color: RGBA, alpha: number) {
+ return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
+}
+
+let stashed: { prompt: PromptInfo; cursor: number } | undefined
+
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@@ -85,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs()
const sdk = useSDK()
const route = useRoute()
+ const project = useProject()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
@@ -95,6 +106,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
+ const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
@@ -233,9 +245,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
hidden: true,
- onSelect: (dialog) => {
+ onSelect: async (dialog) => {
if (!input.focused) return
- void submit()
+ const handled = await submit()
+ if (!handled) return
+
dialog.clear()
},
},
@@ -433,26 +447,47 @@ export function Prompt(props: PromptProps) {
},
}
+ onMount(() => {
+ const saved = stashed
+ stashed = undefined
+ if (store.prompt.input) return
+ if (saved && saved.prompt.input) {
+ input.setText(saved.prompt.input)
+ setStore("prompt", saved.prompt)
+ restoreExtmarksFromParts(saved.prompt.parts)
+ input.cursorOffset = saved.cursor
+ }
+ })
+
onCleanup(() => {
+ if (store.prompt.input) {
+ stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
+ }
props.ref?.(undefined)
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
- input.blur()
+ if (input.focused) input.blur()
return
}
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
- input.focus()
+ if (!input.focused) input.focus()
})
createEffect(() => {
if (!input || input.isDestroyed) return
+ const capture =
+ store.mode === "normal"
+ ? auto()?.visible
+ ? (["escape", "navigate", "submit", "tab"] as const)
+ : (["tab"] as const)
+ : undefined
input.traits = {
- capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
+ capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
@@ -599,20 +634,48 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
- if (props.disabled) return
- if (autocomplete?.visible) return
- if (!store.prompt.input) return
+ if (props.disabled) return false
+ if (autocomplete?.visible) return false
+ if (!store.prompt.input) return false
const agent = local.agent.current()
- if (!agent) return
+ if (!agent) return false
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
- return
+ return true
}
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
- return
+ return false
+ }
+
+ const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
+ const workspaceID = workspaceSession?.workspaceID
+ const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
+ if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
+ dialog.replace(() => (
+ <DialogWorkspaceUnavailable
+ onRestore={() => {
+ dialog.replace(() => (
+ <DialogWorkspaceCreate
+ onSelect={(nextWorkspaceID) =>
+ restoreWorkspaceSession({
+ dialog,
+ sdk,
+ sync,
+ project,
+ toast,
+ workspaceID: nextWorkspaceID,
+ sessionID: props.sessionID!,
+ })
+ }
+ />
+ ))
+ }}
+ />
+ ))
+ return false
}
let sessionID = props.sessionID
@@ -627,7 +690,7 @@ export function Prompt(props: PromptProps) {
variant: "error",
})
- return
+ return true
}
sessionID = res.data.id
@@ -741,6 +804,7 @@ export function Prompt(props: PromptProps) {
})
}, 50)
input.clear()
+ return true
}
const exit = useExit()
@@ -841,6 +905,14 @@ export function Prompt(props: PromptProps) {
return !!current
})
+ const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
+ const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
+ const variantMetaAlpha = createFadeIn(
+ () => !!local.agent.current() && store.mode === "normal" && showVariant(),
+ animationsEnabled,
+ )
+ const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
+
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") {
@@ -901,7 +973,7 @@ export function Prompt(props: PromptProps) {
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
- borderColor={highlight()}
+ borderColor={borderHighlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
@@ -1116,17 +1188,25 @@ export function Prompt(props: PromptProps) {
<Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => (
<>
- <text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
+ <text fg={fadeColor(highlight(), agentMetaAlpha())}>
+ {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
+ </text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
- <text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
+ <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
+ <text
+ flexShrink={0}
+ fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
+ >
{local.model.parsed().model}
</text>
- <text fg={theme.textMuted}>{currentProviderLabel()}</text>
+ <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
- <text fg={theme.textMuted}>·</text>
+ <text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text>
- <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
+ <span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
+ {local.model.variant.current()}
+ </span>
</text>
</Show>
</box>
@@ -1146,7 +1226,7 @@ export function Prompt(props: PromptProps) {
<box
height={1}
border={["left"]}
- borderColor={highlight()}
+ borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
diff --git a/packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts b/packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts
deleted file mode 100644
index 01bc136f5..000000000
--- a/packages/opencode/src/cli/cmd/tui/component/shimmer-config.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-export type ShimmerConfig = {
- period: number
- rings: number
- sweepFraction: number
- coreWidth: number
- coreAmp: number
- softWidth: number
- softAmp: number
- tail: number
- tailAmp: number
- haloWidth: number
- haloOffset: number
- haloAmp: number
- breathBase: number
- noise: number
- ambientAmp: number
- ambientCenter: number
- ambientWidth: number
- shadowMix: number
- primaryMix: number
- originX: number
- originY: number
-}
-
-export const shimmerDefaults: ShimmerConfig = {
- period: 4600,
- rings: 2,
- sweepFraction: 1,
- coreWidth: 1.2,
- coreAmp: 1.9,
- softWidth: 10,
- softAmp: 1.6,
- tail: 5,
- tailAmp: 0.64,
- haloWidth: 4.3,
- haloOffset: 0.6,
- haloAmp: 0.16,
- breathBase: 0.04,
- noise: 0.1,
- ambientAmp: 0.36,
- ambientCenter: 0.5,
- ambientWidth: 0.34,
- shadowMix: 0.1,
- primaryMix: 0.3,
- originX: 4.5,
- originY: 13.5,
-}
-
-export const shimmerConfig: ShimmerConfig = { ...shimmerDefaults }
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
index 9323dd979..a7f50ddf9 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
@@ -132,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
}
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
- const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
- const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
+ const files = [
+ ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
+ ...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
+ ]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
index 66569efea..ed79e8e52 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
@@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
- plugin: ConfigPlugin.Spec.array().optional(),
+ plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
index 1a5e49bad..9d5cd65bf 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts
@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
-import { makeRuntime } from "@/cli/effect/runtime"
+import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
+import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" })
@@ -89,15 +89,13 @@ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
acc.result.plugin_origins = plugins
}
-async function loadState(ctx: { directory: string }) {
+const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
- const directories = await ConfigPaths.directories(ctx.directory)
- // One-time migration: extract tui keys (theme/keybinds/tui) from existing
- // opencode.json files into sibling tui.json files.
- await migrateTuiConfig({ directories, cwd: ctx.directory })
+ const directories = yield* ConfigPaths.directories(ctx.directory)
+ yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
- const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
+ const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
const acc: Acc = {
result: {},
@@ -105,18 +103,19 @@ async function loadState(ctx: { directory: string }) {
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
- await mergeFile(acc, file, ctx)
+ yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
- await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
- log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
+ const configFile = Flag.OPENCODE_TUI_CONFIG
+ yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
+ log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
- await mergeFile(acc, file, ctx)
+ yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
@@ -127,7 +126,7 @@ async function loadState(ctx: { directory: string }) {
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
- await mergeFile(acc, file, ctx)
+ yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
}
@@ -146,20 +145,25 @@ async function loadState(ctx: { directory: string }) {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
-}
+})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
- const data = yield* Effect.promise(() => loadState({ directory }))
+ const data = yield* loadState({ directory })
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
- add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
+ add: [
+ {
+ name: "@opencode-ai/plugin",
+ version: InstallationLocal ? undefined : InstallationVersion,
+ },
+ ],
})
.pipe(Effect.forkScoped),
{
@@ -176,7 +180,7 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
-export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx
index 803752e76..43266315b 100644
--- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx
@@ -1,7 +1,9 @@
import { Global } from "@/global"
import { Filesystem } from "@/util"
+import { Flock } from "@opencode-ai/shared/util/flock"
+import { rename, rm } from "fs/promises"
import { createSignal, type Setter } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
@@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json")
+ const lock = `tui-kv:${filePath}`
+ // Queue same-process writes so rapid updates persist in order.
+ let write = Promise.resolve()
- Filesystem.readJson<Record<string, any>>(filePath)
+ // Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence.
+ function writeSnapshot(snapshot: Record<string, any>) {
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
+ return Filesystem.writeJson(tempPath, snapshot)
+ .then(() => rename(tempPath, filePath))
+ .catch(async (error) => {
+ await rm(tempPath, { force: true }).catch(() => undefined)
+ throw error
+ })
+ }
+
+ // Read under the same lock used for writes because kv.json is shared across processes.
+ Flock.withLock(lock, () => Filesystem.readJson<Record<string, any>>(filePath))
.then((x) => {
setStore(x)
})
- .catch(() => {})
+ .catch((error) => {
+ console.error("Failed to read KV state", { filePath, error })
+ })
.finally(() => {
setReady(true)
})
@@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
},
set(key: string, value: any) {
setStore(key, value)
- void Filesystem.writeJson(filePath, store)
+ const snapshot = structuredClone(unwrap(store))
+ write = write
+ .then(() => Flock.withLock(lock, () => writeSnapshot(snapshot)))
+ .catch((error) => {
+ console.error("Failed to write KV state", { filePath, error })
+ })
},
}
return result
diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx
index bb73c6537..910483764 100644
--- a/packages/opencode/src/cli/cmd/tui/context/local.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx
@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
move(direction: 1 | -1) {
batch(() => {
- let next = agents().findIndex((x) => x.name === agentStore.current) + direction
+ const current = this.current()
+ if (!current) return
+ let next = agents().findIndex((x) => x.name === current.name) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
diff --git a/packages/opencode/src/cli/cmd/tui/context/project.tsx b/packages/opencode/src/cli/cmd/tui/context/project.tsx
index 26e5c075d..22dd94bc8 100644
--- a/packages/opencode/src/cli/cmd/tui/context/project.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/project.tsx
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
name: "Project",
init: () => {
const sdk = useSDK()
+
+ const defaultPath = {
+ home: "",
+ state: "",
+ config: "",
+ worktree: "",
+ directory: sdk.directory ?? "",
+ } satisfies Path
+
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
- path: {
- home: "",
- state: "",
- config: "",
- worktree: "",
- directory: sdk.directory ?? "",
- } satisfies Path,
+ path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
])
batch(() => {
- setStore("instance", "path", reconcile(path.data!))
+ setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id)
})
}
diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx
index e9f463a13..35be17801 100644
--- a/packages/opencode/src/cli/cmd/tui/context/route.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx
@@ -1,16 +1,16 @@
-import { createStore } from "solid-js/store"
+import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
- initialPrompt?: PromptInfo
+ prompt?: PromptInfo
}
export type SessionRoute = {
type: "session"
sessionID: string
- initialPrompt?: PromptInfo
+ prompt?: PromptInfo
}
export type PluginRoute = {
@@ -23,13 +23,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
- init: () => {
+ init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>(
- process.env["OPENCODE_ROUTE"]
- ? JSON.parse(process.env["OPENCODE_ROUTE"])
- : {
- type: "home",
- },
+ props.initialRoute ??
+ (process.env["OPENCODE_ROUTE"]
+ ? JSON.parse(process.env["OPENCODE_ROUTE"])
+ : {
+ type: "home",
+ }),
)
return {
@@ -37,7 +38,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
- setStore(route)
+ setStore(reconcile(route))
},
}
},
diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
index 14d306288..6a240ceef 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx
@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { Flag } from "@/flag/flag"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
+ const retryDelay = 1000
+ const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const ctrl = new AbortController()
sse = ctrl
;(async () => {
+ let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
- const events = await sdk.global.event({ signal: ctrl.signal })
+
+ const events = await sdk.global.event({
+ signal: ctrl.signal,
+ sseMaxRetryAttempts: 0,
+ })
+
+ if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+ // Start syncing workspaces, it's important to do this after
+ // we've started listening to events
+ await sdk.sync.start().catch(() => {})
+ }
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
+ attempt += 1
+ if (abort.signal.aborted || ctrl.signal.aborted) break
+
+ // Exponential backoff
+ const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
+ await new Promise((resolve) => setTimeout(resolve, backoff))
}
})().catch(() => {})
}
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
+
+ if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+ // Start syncing workspaces, it's important to do this after
+ // we've started listening to events
+ await sdk.sync.start().catch(() => {})
+ }
} else {
startSSE()
}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index b5734e67d..57326e3a1 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
-import { batch, createEffect, on } from "solid-js"
+import { batch, onMount } from "solid-js"
import { Log } from "@/util"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
+ const fullSyncedSessions = new Set<string>()
+ let syncedWorkspace = project.workspace.current()
+
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit()
const args = useArgs()
- async function bootstrap() {
- console.log("bootstrapping")
+ async function bootstrap(input: { fatal?: boolean } = {}) {
+ const fatal = input.fatal ?? true
const workspace = project.workspace.current()
+ if (workspace !== syncedWorkspace) {
+ fullSyncedSessions.clear()
+ syncedWorkspace = workspace
+ }
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
- await exit(e)
+ if (fatal) {
+ await exit(e)
+ } else {
+ throw e
+ }
})
}
- const fullSyncedSessions = new Set<string>()
- createEffect(
- on(
- () => project.workspace.current(),
- () => {
- fullSyncedSessions.clear()
- void bootstrap()
- },
- ),
- )
+ onMount(() => {
+ void bootstrap()
+ })
const result = {
data: store,
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 679be8f25..af9582cfb 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -314,8 +314,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
- const mode = pick(kv.get("theme_mode", props.mode))
- draft.mode = lock ?? mode ?? props.mode
+ const mode = lock ?? props.mode
+ if (!lock && pick(kv.get("theme_mode")) !== undefined) {
+ kv.set("theme_mode", undefined)
+ }
+ draft.mode = mode
draft.lock = lock
const active = config.theme ?? kv.get("theme", "opencode")
draft.active = typeof active === "string" ? active : "opencode"
@@ -373,7 +376,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
function apply(mode: "dark" | "light") {
- kv.set("theme_mode", mode)
+ if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache()
@@ -389,6 +392,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
+ kv.set("theme_mode", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
diff --git a/packages/opencode/src/cli/cmd/tui/layer.ts b/packages/opencode/src/cli/cmd/tui/layer.ts
index 734106f8a..64cba08e8 100644
--- a/packages/opencode/src/cli/cmd/tui/layer.ts
+++ b/packages/opencode/src/cli/cmd/tui/layer.ts
@@ -1,6 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
-import { Npm } from "@opencode-ai/shared/npm"
+import { Npm } from "@/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
index d2b495ca3..5bea48380 100644
--- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
+++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx
@@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
name: "session",
params: {
sessionID: route.data.sessionID,
- initialPrompt: route.data.initialPrompt,
+ prompt: route.data.prompt,
},
}
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
index 1cce7fb39..2f0ff07e9 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
-// TODO: what is the best way to do this?
let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
@@ -31,8 +30,8 @@ export function Home() {
setRef(r)
promptRef.set(r)
if (once || !r) return
- if (route.initialPrompt) {
- r.set(route.initialPrompt)
+ if (route.prompt) {
+ r.set(route.prompt)
once = true
return
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
index 0ce33a59a..7414cefd3 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
@@ -5,11 +5,11 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
-import { useDialog } from "../../ui/dialog"
+import { useDialog, type DialogContext } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
-export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
+export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
@@ -19,9 +19,21 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
dialog.setSize("large")
})
- const options = createMemo((): DialogSelectOption<string>[] => {
+ const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const messages = sync.data.message[props.sessionID] ?? []
- const result = [] as DialogSelectOption<string>[]
+ const fullSession = {
+ title: "Full session",
+ value: undefined,
+ onSelect: async (dialog: DialogContext) => {
+ const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
+ route.navigate({
+ sessionID: forked.data!.id,
+ type: "session",
+ })
+ dialog.clear()
+ },
+ } satisfies DialogSelectOption<string | undefined>
+ const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
@@ -38,7 +50,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
messageID: message.id,
})
const parts = sync.data.part[message.id] ?? []
- const initialPrompt = parts.reduce(
+ const prompt = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
@@ -51,15 +63,14 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
route.navigate({
sessionID: forked.data!.id,
type: "session",
- initialPrompt,
+ prompt,
})
dialog.clear()
},
})
}
- result.reverse()
- return result
+ return [fullSession, ...result.reverse()]
})
- return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
+ return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
index 412b4d87e..aeea2f52a 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
@@ -81,25 +81,23 @@ export function DialogMessage(props: {
sessionID: props.sessionID,
messageID: props.messageID,
})
- const initialPrompt = (() => {
- const msg = message()
- if (!msg) return undefined
- const parts = sync.data.part[msg.id]
- return parts.reduce(
- (agg, part) => {
- if (part.type === "text") {
- if (!part.synthetic) agg.input += part.text
- }
- if (part.type === "file") agg.parts.push(part)
- return agg
- },
- { input: "", parts: [] as PromptInfo["parts"] },
- )
- })()
+ const msg = message()
+ const prompt = msg
+ ? sync.data.part[msg.id].reduce(
+ (agg, part) => {
+ if (part.type === "text") {
+ if (!part.synthetic) agg.input += part.text
+ }
+ if (part.type === "file") agg.parts.push(part)
+ return agg
+ },
+ { input: "", parts: [] as PromptInfo["parts"] },
+ )
+ : undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
- initialPrompt,
+ prompt,
})
dialog.clear()
},
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 7c40e6c3c..06be5dfbe 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -181,27 +181,32 @@ export function Session() {
const sdk = useSDK()
createEffect(async () => {
- await sdk.client.session
- .get({ sessionID: route.sessionID }, { throwOnError: true })
- .then((x) => {
- project.workspace.set(x.data?.workspaceID)
- })
- .then(() => sync.session.sync(route.sessionID))
- .then(() => {
- if (scroll) scroll.scrollBy(100_000)
- })
- .catch((e) => {
- console.error(e)
- toast.show({
- message: `Session not found: ${route.sessionID}`,
- variant: "error",
- })
- return navigate({ type: "home" })
+ const previousWorkspace = project.workspace.current()
+ const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
+ if (!result.data) {
+ toast.show({
+ message: `Session not found: ${route.sessionID}`,
+ variant: "error",
})
+ navigate({ type: "home" })
+ return
+ }
+
+ if (result.data.workspaceID !== previousWorkspace) {
+ project.workspace.set(result.data.workspaceID)
+
+ // Sync all the data for this workspace. Note that this
+ // workspace may not exist anymore which is why this is not
+ // fatal. If it doesn't we still want to show the session
+ // (which will be non-interactive)
+ try {
+ await sync.bootstrap({ fatal: false })
+ } catch (e) {}
+ }
+ await sync.session.sync(route.sessionID)
+ if (scroll) scroll.scrollBy(100_000)
})
- // Handle initial prompt from fork
- let seeded = false
let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
@@ -219,14 +224,15 @@ export function Session() {
}
})
+ let seeded = false
let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
promptRef.set(r)
- if (seeded || !route.initialPrompt || !r) return
+ if (seeded || !route.prompt || !r) return
seeded = true
- r.set(route.initialPrompt)
+ r.set(route.prompt)
}
const keybind = useKeybind()
const dialog = useDialog()
@@ -445,7 +451,7 @@ export function Session() {
},
},
{
- title: "Fork from message",
+ title: "Fork session",
value: "session.fork",
keybind: "session_fork",
category: "Session",
@@ -456,6 +462,7 @@ export function Session() {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
+ if (!messageID) return
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index 06bc27064..6d92752ef 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -1,17 +1,31 @@
+import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
-import { InstallationVersion } from "@/installation/version"
+import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
+ const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
+ const workspaceStatus = () => {
+ const workspaceID = session()?.workspaceID
+ if (!workspaceID) return "error"
+ return project.workspace.status(workspaceID) ?? "error"
+ }
+ const workspaceLabel = () => {
+ const workspaceID = session()?.workspaceID
+ if (!workspaceID) return "unknown"
+ const info = project.workspace.get(workspaceID)
+ if (!info) return "unknown"
+ return `${info.type}: ${info.name}`
+ }
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
@@ -48,6 +62,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
+ <Show when={InstallationChannel !== "latest"}>
+ <text fg={theme.textMuted}>{props.sessionID}</text>
+ </Show>
+ <Show when={session()!.workspaceID}>
+ <text fg={theme.textMuted}>
+ <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
+ {workspaceLabel()}
+ </text>
+ </Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 96ceb905c..e3e9eb811 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
+import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
+ const env = sanitizedProcessEnv({
+ [OPENCODE_PROCESS_ROLE]: "worker",
+ [OPENCODE_RUN_ID]: ensureRunID(),
+ })
const worker = new Worker(file, {
- env: Object.fromEntries(
- Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
- ),
+ env,
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
diff --git a/packages/opencode/src/cli/cmd/tui/util/signal.ts b/packages/opencode/src/cli/cmd/tui/util/signal.ts
index 15b57886d..7d20ae04b 100644
--- a/packages/opencode/src/cli/cmd/tui/util/signal.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/signal.ts
@@ -1,7 +1,41 @@
-import { createSignal, type Accessor } from "solid-js"
+import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
const [get, set] = createSignal(value)
return [get, debounce((v: T) => set(() => v), ms)]
}
+
+export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
+ const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
+ let revealed = show()
+
+ createEffect(
+ on([show, enabled], ([visible, animate]) => {
+ if (!visible) {
+ setAlpha(0)
+ return
+ }
+
+ if (!animate || revealed) {
+ revealed = true
+ setAlpha(1)
+ return
+ }
+
+ const start = performance.now()
+ revealed = true
+ setAlpha(0)
+
+ const timer = setInterval(() => {
+ const progress = Math.min((performance.now() - start) / 160, 1)
+ setAlpha(progress * progress * (3 - 2 * progress))
+ if (progress >= 1) clearInterval(timer)
+ }, 16)
+
+ onCleanup(() => clearInterval(timer))
+ }),
+ )
+
+ return alpha
+}
diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts
index 46cf4635a..c026b7381 100644
--- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts
@@ -17,12 +17,6 @@ function parse(color: string): RGBA | null {
return null
}
-function mode(bg: RGBA | null): "dark" | "light" {
- if (!bg) return "dark"
- const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
- return luminance > 0.5 ? "light" : "dark"
-}
-
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -100,36 +94,3 @@ export async function colors(): Promise<{
}, 1000)
})
}
-
-// Keep startup mode detection separate from `colors()`: the TUI boot path only
-// needs OSC 11 and should resolve on the first background response instead of
-// waiting on the full palette query used by system theme generation.
-export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
- if (!process.stdin.isTTY) return "dark"
-
- return new Promise((resolve) => {
- let timeout: NodeJS.Timeout
-
- const cleanup = () => {
- process.stdin.setRawMode(false)
- process.stdin.removeListener("data", handler)
- clearTimeout(timeout)
- }
-
- const handler = (data: Buffer) => {
- const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
- if (!match) return
- cleanup()
- resolve(mode(parse(match[1])))
- }
-
- process.stdin.setRawMode(true)
- process.stdin.on("data", handler)
- process.stdout.write("\x1b]11;?\x07")
-
- timeout = setTimeout(() => {
- cleanup()
- resolve("dark")
- }, 1000)
- })
-}
diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts
index 393a407eb..8cec99c61 100644
--- a/packages/opencode/src/cli/cmd/tui/worker.ts
+++ b/packages/opencode/src/cli/cmd/tui/worker.ts
@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
+import { ensureProcessMetadata } from "@/util/opencode-process"
+
+ensureProcessMetadata("worker")
await Log.init({
print: process.argv.includes("--print-logs"),
diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts
index f754f009d..1469522d9 100644
--- a/packages/opencode/src/config/agent.ts
+++ b/packages/opencode/src/config/agent.ts
@@ -1,10 +1,12 @@
export * as ConfigAgent from "./agent"
-import { Log } from "../util"
+import { Schema } from "effect"
import z from "zod"
+import { Bus } from "@/bus"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
-import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -13,89 +15,102 @@ import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
-export const Info = z
- .object({
- model: ConfigModelID.optional(),
- variant: z
- .string()
- .optional()
- .describe("Default model variant for this agent (applies only when using the agent's configured model)."),
- temperature: z.number().optional(),
- top_p: z.number().optional(),
- prompt: z.string().optional(),
- tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
- disable: z.boolean().optional(),
- description: z.string().optional().describe("Description of when to use the agent"),
- mode: z.enum(["subagent", "primary", "all"]).optional(),
- hidden: z
- .boolean()
- .optional()
- .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
- options: z.record(z.string(), z.any()).optional(),
- color: z
- .union([
- z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
- z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
- ])
- .optional()
- .describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
- steps: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Maximum number of agentic iterations before forcing text-only response"),
- maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
- permission: ConfigPermission.Info.optional(),
- })
- .catchall(z.any())
- .transform((agent, _ctx) => {
- const knownKeys = new Set([
- "name",
- "model",
- "variant",
- "prompt",
- "description",
- "temperature",
- "top_p",
- "mode",
- "hidden",
- "color",
- "steps",
- "maxSteps",
- "options",
- "permission",
- "disable",
- "tools",
- ])
-
- const options: Record<string, unknown> = { ...agent.options }
- for (const [key, value] of Object.entries(agent)) {
- if (!knownKeys.has(key)) options[key] = value
- }
+const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
- const permission: ConfigPermission.Info = {}
- for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
- const action = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
- permission.edit = action
- continue
- }
- permission[tool] = action
- }
- Object.assign(permission, agent.permission)
+const Color = Schema.Union([
+ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
+ Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
+])
+
+// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
+// shape lives outside the Effect Schema type system), so the walker reaches it
+// via ZodOverride rather than a pure Schema reference. This preserves the
+// `$ref: PermissionConfig` emitted in openapi.json.
+const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
+
+const AgentSchema = Schema.StructWithRest(
+ Schema.Struct({
+ model: Schema.optional(ConfigModelID),
+ variant: Schema.optional(Schema.String).annotate({
+ description: "Default model variant for this agent (applies only when using the agent's configured model).",
+ }),
+ temperature: Schema.optional(Schema.Number),
+ top_p: Schema.optional(Schema.Number),
+ prompt: Schema.optional(Schema.String),
+ tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
+ description: "@deprecated Use 'permission' field instead",
+ }),
+ disable: Schema.optional(Schema.Boolean),
+ description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
+ mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
+ hidden: Schema.optional(Schema.Boolean).annotate({
+ description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
+ }),
+ options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ color: Schema.optional(Color).annotate({
+ description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
+ }),
+ steps: Schema.optional(PositiveInt).annotate({
+ description: "Maximum number of agentic iterations before forcing text-only response",
+ }),
+ maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
+ permission: Schema.optional(PermissionRef),
+ }),
+ [Schema.Record(Schema.String, Schema.Any)],
+)
- const steps = agent.steps ?? agent.maxSteps
+const KNOWN_KEYS = new Set([
+ "name",
+ "model",
+ "variant",
+ "prompt",
+ "description",
+ "temperature",
+ "top_p",
+ "mode",
+ "hidden",
+ "color",
+ "steps",
+ "maxSteps",
+ "options",
+ "permission",
+ "disable",
+ "tools",
+])
- return { ...agent, options, permission, steps } as typeof agent & {
- options?: Record<string, unknown>
- permission?: ConfigPermission.Info
- steps?: number
+// Post-parse normalisation:
+// - Promote any unknown-but-present keys into `options` so they survive the
+// round-trip in a well-known field.
+// - Translate the deprecated `tools: { name: boolean }` map into the new
+// `permission` shape (write-adjacent tools collapse into `permission.edit`).
+// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
+const normalize = (agent: z.infer<typeof Info>) => {
+ const options: Record<string, unknown> = { ...agent.options }
+ for (const [key, value] of Object.entries(agent)) {
+ if (!KNOWN_KEYS.has(key)) options[key] = value
+ }
+
+ const permission: ConfigPermission.Info = {}
+ for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
+ const action = enabled ? "allow" : "deny"
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ permission.edit = action
+ continue
}
- })
- .meta({
- ref: "AgentConfig",
- })
+ permission[tool] = action
+ }
+ globalThis.Object.assign(permission, agent.permission)
+
+ return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
+}
+
+export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
+ Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
+ options?: Record<string, unknown>
+ permission?: ConfigPermission.Info
+ steps?: number
+ }
+>
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts
index 979925056..3e0adccc3 100644
--- a/packages/opencode/src/config/command.ts
+++ b/packages/opencode/src/config/command.ts
@@ -1,10 +1,12 @@
export * as ConfigCommand from "./command"
import { Log } from "../util"
-import z from "zod"
+import { Schema } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
const log = Log.create({ service: "config" })
-export const Info = z.object({
- template: z.string(),
- description: z.string().optional(),
- agent: z.string().optional(),
- model: ConfigModelID.optional(),
- subtask: z.boolean().optional(),
-})
+export const Info = Schema.Struct({
+ template: Schema.String,
+ description: Schema.optional(Schema.String),
+ agent: Schema.optional(Schema.String),
+ model: Schema.optional(ConfigModelID),
+ subtask: Schema.optional(Schema.Boolean),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export type Info = z.infer<typeof Info>
+export type Info = Schema.Schema.Type<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
@@ -49,7 +51,7 @@ export async function load(dir: string) {
...md.data,
template: md.content.trim(),
}
- const parsed = Info.safeParse(config)
+ const parsed = Info.zod.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 455a6e826..2b0c97eae 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -21,24 +21,27 @@ import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
-import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
+import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
-import { Npm } from "@opencode-ai/shared/npm"
+import { zod, ZodOverride } from "@/util/effect-zod"
import { ConfigAgent } from "./agent"
+import { ConfigCommand } from "./command"
+import { ConfigFormatter } from "./formatter"
+import { ConfigLayout } from "./layout"
+import { ConfigLSP } from "./lsp"
+import { ConfigManaged } from "./managed"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
-import { ConfigPlugin } from "./plugin"
-import { ConfigManaged } from "./managed"
-import { ConfigCommand } from "./command"
import { ConfigParse } from "./parse"
+import { ConfigPaths } from "./paths"
import { ConfigPermission } from "./permission"
+import { ConfigPlugin } from "./plugin"
import { ConfigProvider } from "./provider"
+import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
-import { ConfigPaths } from "./paths"
-import { ConfigFormatter } from "./formatter"
-import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
+import { Npm } from "@/npm"
const log = Log.create({ service: "config" })
@@ -73,184 +76,193 @@ async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(
return config
}
-export const Server = z
- .object({
- port: z.number().int().positive().optional().describe("Port to listen on"),
- hostname: z.string().optional().describe("Hostname to listen on"),
- mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
- mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
- cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
- })
- .strict()
- .meta({
- ref: "ServerConfig",
- })
+export const Server = ConfigServer.Server.zod
+export const Layout = ConfigLayout.Layout.zod
+export type Layout = ConfigLayout.Layout
-export const Layout = z.enum(["auto", "stretch"]).meta({
- ref: "LayoutConfig",
-})
-export type Layout = z.infer<typeof Layout>
-
-export const Info = z
- .object({
- $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
- logLevel: Log.Level.optional().describe("Log level"),
- server: Server.optional().describe("Server configuration for opencode serve and web commands"),
- command: z
- .record(z.string(), ConfigCommand.Info)
- .optional()
- .describe("Command configuration, see https://opencode.ai/docs/commands"),
- skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
- watcher: z
- .object({
- ignore: z.array(z.string()).optional(),
- })
- .optional(),
- snapshot: z
- .boolean()
- .optional()
- .describe(
- "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
- ),
- // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
- plugin: ConfigPlugin.Spec.array().optional(),
- share: z
- .enum(["manual", "auto", "disabled"])
- .optional()
- .describe(
- "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
- ),
- autoshare: z
- .boolean()
- .optional()
- .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
- autoupdate: z
- .union([z.boolean(), z.literal("notify")])
- .optional()
- .describe(
- "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
- ),
- disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
- enabled_providers: z
- .array(z.string())
- .optional()
- .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
- model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
- small_model: ConfigModelID.describe(
- "Small model to use for tasks like title generation in the format of provider/model",
- ).optional(),
- default_agent: z
- .string()
- .optional()
- .describe(
- "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
- ),
- username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
- mode: z
- .object({
- build: ConfigAgent.Info.optional(),
- plan: ConfigAgent.Info.optional(),
- })
- .catchall(ConfigAgent.Info)
- .optional()
- .describe("@deprecated Use `agent` field instead."),
- agent: z
- .object({
+// Schemas that still live at the zod layer (have .transform / .preprocess /
+// .meta not expressible in current Effect Schema) get referenced via a
+// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
+// exact zod directly, preserving component $refs.
+const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
+const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
+const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
+
+const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
+const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
+
+const InfoSchema = Schema.Struct({
+ $schema: Schema.optional(Schema.String).annotate({
+ description: "JSON schema reference for configuration validation",
+ }),
+ logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
+ server: Schema.optional(ConfigServer.Server).annotate({
+ description: "Server configuration for opencode serve and web commands",
+ }),
+ command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
+ description: "Command configuration, see https://opencode.ai/docs/commands",
+ }),
+ skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
+ watcher: Schema.optional(
+ Schema.Struct({
+ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ }),
+ ),
+ snapshot: Schema.optional(Schema.Boolean).annotate({
+ description:
+ "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
+ }),
+ // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
+ plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
+ share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
+ description:
+ "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
+ }),
+ autoshare: Schema.optional(Schema.Boolean).annotate({
+ description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
+ }),
+ autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
+ description:
+ "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
+ }),
+ disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
+ description: "Disable providers that are loaded automatically",
+ }),
+ enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
+ description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
+ }),
+ model: Schema.optional(ConfigModelID).annotate({
+ description: "Model to use in the format of provider/model, eg anthropic/claude-2",
+ }),
+ small_model: Schema.optional(ConfigModelID).annotate({
+ description: "Small model to use for tasks like title generation in the format of provider/model",
+ }),
+ default_agent: Schema.optional(Schema.String).annotate({
+ description:
+ "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
+ }),
+ username: Schema.optional(Schema.String).annotate({
+ description: "Custom username to display in conversations instead of system username",
+ }),
+ mode: Schema.optional(
+ Schema.StructWithRest(
+ Schema.Struct({
+ build: Schema.optional(AgentRef),
+ plan: Schema.optional(AgentRef),
+ }),
+ [Schema.Record(Schema.String, AgentRef)],
+ ),
+ ).annotate({ description: "@deprecated Use `agent` field instead." }),
+ agent: Schema.optional(
+ Schema.StructWithRest(
+ Schema.Struct({
// primary
- plan: ConfigAgent.Info.optional(),
- build: ConfigAgent.Info.optional(),
+ plan: Schema.optional(AgentRef),
+ build: Schema.optional(AgentRef),
// subagent
- general: ConfigAgent.Info.optional(),
- explore: ConfigAgent.Info.optional(),
+ general: Schema.optional(AgentRef),
+ explore: Schema.optional(AgentRef),
// specialized
- title: ConfigAgent.Info.optional(),
- summary: ConfigAgent.Info.optional(),
- compaction: ConfigAgent.Info.optional(),
- })
- .catchall(ConfigAgent.Info)
- .optional()
- .describe("Agent configuration, see https://opencode.ai/docs/agents"),
- provider: z
- .record(z.string(), ConfigProvider.Info)
- .optional()
- .describe("Custom provider configurations and model overrides"),
- mcp: z
- .record(
- z.string(),
- z.union([
- ConfigMCP.Info,
- z
- .object({
- enabled: z.boolean(),
- })
- .strict(),
- ]),
- )
- .optional()
- .describe("MCP (Model Context Protocol) server configurations"),
- formatter: ConfigFormatter.Info.optional(),
- lsp: ConfigLSP.Info.optional(),
- instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
- layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
- permission: ConfigPermission.Info.optional(),
- tools: z.record(z.string(), z.boolean()).optional(),
- enterprise: z
- .object({
- url: z.string().optional().describe("Enterprise URL"),
- })
- .optional(),
- compaction: z
- .object({
- auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
- prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
- tail_turns: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe(
- "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
- ),
- tail_tokens: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe("Token budget for retained recent turn spans during compaction"),
- reserved: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
- })
- .optional(),
- experimental: z
- .object({
- disable_paste_summary: z.boolean().optional(),
- batch_tool: z.boolean().optional().describe("Enable the batch tool"),
- openTelemetry: z
- .boolean()
- .optional()
- .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
- primary_tools: z
- .array(z.string())
- .optional()
- .describe("Tools that should only be available to primary agents."),
- continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
- mcp_timeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in milliseconds for model context protocol (MCP) requests"),
- })
- .optional(),
- })
+ title: Schema.optional(AgentRef),
+ summary: Schema.optional(AgentRef),
+ compaction: Schema.optional(AgentRef),
+ }),
+ [Schema.Record(Schema.String, AgentRef)],
+ ),
+ ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
+ provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
+ description: "Custom provider configurations and model overrides",
+ }),
+ mcp: Schema.optional(
+ Schema.Record(
+ Schema.String,
+ Schema.Union([
+ ConfigMCP.Info,
+ // Matches the legacy `{ enabled: false }` form used to disable a server.
+ Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
+ ]),
+ ),
+ ).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
+ formatter: Schema.optional(ConfigFormatter.Info),
+ lsp: Schema.optional(ConfigLSP.Info),
+ instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
+ description: "Additional instruction files or patterns to include",
+ }),
+ layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
+ permission: Schema.optional(PermissionRef),
+ tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
+ enterprise: Schema.optional(
+ Schema.Struct({
+ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
+ }),
+ ),
+ compaction: Schema.optional(
+ Schema.Struct({
+ auto: Schema.optional(Schema.Boolean).annotate({
+ description: "Enable automatic compaction when context is full (default: true)",
+ }),
+ prune: Schema.optional(Schema.Boolean).annotate({
+ description: "Enable pruning of old tool outputs (default: true)",
+ }),
+ tail_turns: Schema.optional(NonNegativeInt).annotate({
+ description:
+ "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
+ }),
+ tail_tokens: Schema.optional(NonNegativeInt).annotate({
+ description: "Token budget for retained recent turn spans during compaction",
+ }),
+ reserved: Schema.optional(NonNegativeInt).annotate({
+ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
+ }),
+ }),
+ ),
+ experimental: Schema.optional(
+ Schema.Struct({
+ disable_paste_summary: Schema.optional(Schema.Boolean),
+ batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
+ openTelemetry: Schema.optional(Schema.Boolean).annotate({
+ description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
+ }),
+ primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
+ description: "Tools that should only be available to primary agents.",
+ }),
+ continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
+ description: "Continue the agent loop when a tool call is denied",
+ }),
+ mcp_timeout: Schema.optional(PositiveInt).annotate({
+ description: "Timeout in milliseconds for model context protocol (MCP) requests",
+ }),
+ }),
+ ),
+})
+
+// Schema.Struct produces readonly types by default, but the service code
+// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
+// readonly recursively so callers get the same mutable shape zod inferred.
+//
+// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
+// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
+// (since `keyof unknown = never`), which widens `Record<string, unknown>`
+// fields like `ConfigPlugin.Options`. The local version gates on
+// `extends object` so `unknown` passes through.
+//
+// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
+// shape (otherwise the general array branch widens it to an array).
+type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
+ ? { -readonly [K in keyof T]: DeepMutable<T[K]> }
+ : T extends readonly (infer U)[]
+ ? DeepMutable<U>[]
+ : T extends object
+ ? { -readonly [K in keyof T]: DeepMutable<T[K]> }
+ : T
+
+// The walker emits `z.object({...})` which is non-strict by default. Config
+// historically uses `.strict()` (additionalProperties: false in openapi.json),
+// so layer that on after derivation. Re-apply the Config ref afterward
+// since `.strict()` strips the walker's meta annotation.
+export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
.strict()
- .meta({
- ref: "Config",
- })
+ .meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
export type Info = z.output<typeof Info> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
@@ -427,260 +439,266 @@ export const layer = Layer.effect(
}
})
- const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
- const auth = yield* authSvc.all().pipe(Effect.orDie)
-
- let result: Info = {}
- const consoleManagedProviders = new Set<string>()
- let activeOrgName: string | undefined
-
- const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
- if (source.startsWith("http://") || source.startsWith("https://")) return "global"
- if (source === "OPENCODE_CONFIG_CONTENT") return "local"
- if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
- return "global"
- })
-
- const mergePluginOrigins = Effect.fnUntraced(function* (
- source: string,
- // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
- // is attached.
- list: ConfigPlugin.Spec[] | undefined,
- // Scope can be inferred from the source path, but some callers already know whether the config should
- // behave as global or local and can pass that explicitly.
- kind?: ConfigPlugin.Scope,
- ) {
- if (!list?.length) return
- const hit = kind ?? (yield* pluginScopeForSource(source))
- // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
- // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
- const plugins = ConfigPlugin.deduplicatePluginOrigins([
- ...(result.plugin_origins ?? []),
- ...list.map((spec) => ({ spec, source, scope: hit })),
- ])
- result.plugin = plugins.map((item) => item.spec)
- result.plugin_origins = plugins
- })
-
- const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
- result = mergeConfigConcatArrays(result, next)
- return mergePluginOrigins(source, next.plugin, kind)
- }
+ const loadInstanceState = Effect.fn("Config.loadInstanceState")(
+ function* (ctx: InstanceContext) {
+ const auth = yield* authSvc.all().pipe(Effect.orDie)
- for (const [key, value] of Object.entries(auth)) {
- if (value.type === "wellknown") {
- const url = key.replace(/\/+$/, "")
- process.env[value.key] = value.token
- log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
- const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
- if (!response.ok) {
- throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
- }
- const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record<string, unknown> }
- const remoteConfig = wellknown.config ?? {}
- if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
- const source = `${url}/.well-known/opencode`
- const next = yield* loadConfig(JSON.stringify(remoteConfig), {
- dir: path.dirname(source),
- source,
- })
- yield* merge(source, next, "global")
- log.debug("loaded remote config from well-known", { url })
- }
- }
+ let result: Info = {}
+ const consoleManagedProviders = new Set<string>()
+ let activeOrgName: string | undefined
- const global = yield* getGlobal()
- yield* merge(Global.Path.config, global, "global")
+ const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
+ if (source.startsWith("http://") || source.startsWith("https://")) return "global"
+ if (source === "OPENCODE_CONFIG_CONTENT") return "local"
+ if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
+ return "global"
+ })
- if (Flag.OPENCODE_CONFIG) {
- yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
- log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
- }
+ const mergePluginOrigins = Effect.fnUntraced(function* (
+ source: string,
+ // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
+ // is attached.
+ list: ConfigPlugin.Spec[] | undefined,
+ // Scope can be inferred from the source path, but some callers already know whether the config should
+ // behave as global or local and can pass that explicitly.
+ kind?: ConfigPlugin.Scope,
+ ) {
+ if (!list?.length) return
+ const hit = kind ?? (yield* pluginScopeForSource(source))
+ // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
+ // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
+ const plugins = ConfigPlugin.deduplicatePluginOrigins([
+ ...(result.plugin_origins ?? []),
+ ...list.map((spec) => ({ spec, source, scope: hit })),
+ ])
+ result.plugin = plugins.map((item) => item.spec)
+ result.plugin_origins = plugins
+ })
- if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of yield* Effect.promise(() =>
- ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
- )) {
- yield* merge(file, yield* loadFile(file), "local")
+ const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
+ result = mergeConfigConcatArrays(result, next)
+ return mergePluginOrigins(source, next.plugin, kind)
}
- }
- result.agent = result.agent || {}
- result.mode = result.mode || {}
- result.plugin = result.plugin || []
-
- const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
+ for (const [key, value] of Object.entries(auth)) {
+ if (value.type === "wellknown") {
+ const url = key.replace(/\/+$/, "")
+ process.env[value.key] = value.token
+ log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
+ const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
+ if (!response.ok) {
+ throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
+ }
+ const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record<string, unknown> }
+ const remoteConfig = wellknown.config ?? {}
+ if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
+ const source = `${url}/.well-known/opencode`
+ const next = yield* loadConfig(JSON.stringify(remoteConfig), {
+ dir: path.dirname(source),
+ source,
+ })
+ yield* merge(source, next, "global")
+ log.debug("loaded remote config from well-known", { url })
+ }
+ }
- if (Flag.OPENCODE_CONFIG_DIR) {
- log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
- }
+ const global = yield* getGlobal()
+ yield* merge(Global.Path.config, global, "global")
- const deps: Fiber.Fiber<void, never>[] = []
+ if (Flag.OPENCODE_CONFIG) {
+ yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
+ log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
+ }
- for (const dir of directories) {
- if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
- for (const file of ["opencode.json", "opencode.jsonc"]) {
- const source = path.join(dir, file)
- log.debug(`loading config from ${source}`)
- yield* merge(source, yield* loadFile(source))
- result.agent ??= {}
- result.mode ??= {}
- result.plugin ??= []
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
+ yield* merge(file, yield* loadFile(file), "local")
}
}
- yield* ensureGitignore(dir).pipe(Effect.orDie)
+ result.agent = result.agent || {}
+ result.mode = result.mode || {}
+ result.plugin = result.plugin || []
- const dep = yield* npmSvc
- .install(dir, {
- add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
- })
- .pipe(
- Effect.exit,
- Effect.tap((exit) =>
- Exit.isFailure(exit)
- ? Effect.sync(() => {
- log.warn("background dependency install failed", { dir, error: String(exit.cause) })
- })
- : Effect.void,
- ),
- Effect.asVoid,
- Effect.forkDetach,
- )
- deps.push(dep)
-
- result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
- result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
- result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
- // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
- // returns normalized Specs and we only need to attach origin metadata here.
- const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
- yield* mergePluginOrigins(dir, list)
- }
-
- if (process.env.OPENCODE_CONFIG_CONTENT) {
- const source = "OPENCODE_CONFIG_CONTENT"
- const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
- dir: ctx.directory,
- source,
- })
- yield* merge(source, next, "local")
- log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
- }
+ const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
- const activeAccount = Option.getOrUndefined(
- yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
- )
- if (activeAccount?.active_org_id) {
- const accountID = activeAccount.id
- const orgID = activeAccount.active_org_id
- const url = activeAccount.url
- yield* Effect.gen(function* () {
- const [configOpt, tokenOpt] = yield* Effect.all(
- [accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
- { concurrency: 2 },
- )
- if (Option.isSome(tokenOpt)) {
- process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
- yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
- }
+ if (Flag.OPENCODE_CONFIG_DIR) {
+ log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+ }
- if (Option.isSome(configOpt)) {
- const source = `${url}/api/config`
- const next = yield* loadConfig(JSON.stringify(configOpt.value), {
- dir: path.dirname(source),
- source,
- })
- for (const providerID of Object.keys(next.provider ?? {})) {
- consoleManagedProviders.add(providerID)
+ const deps: Fiber.Fiber<void, never>[] = []
+
+ for (const dir of directories) {
+ if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
+ const source = path.join(dir, file)
+ log.debug(`loading config from ${source}`)
+ yield* merge(source, yield* loadFile(source))
+ result.agent ??= {}
+ result.mode ??= {}
+ result.plugin ??= []
}
- yield* merge(source, next, "global")
}
- }).pipe(
- Effect.withSpan("Config.loadActiveOrgConfig"),
- Effect.catch((err) => {
- log.debug("failed to fetch remote account config", {
- error: err instanceof Error ? err.message : String(err),
+
+ yield* ensureGitignore(dir).pipe(Effect.orDie)
+
+ const dep = yield* npmSvc
+ .install(dir, {
+ add: [
+ {
+ name: "@opencode-ai/plugin",
+ version: InstallationLocal ? undefined : InstallationVersion,
+ },
+ ],
})
- return Effect.void
- }),
- )
- }
+ .pipe(
+ Effect.exit,
+ Effect.tap((exit) =>
+ Exit.isFailure(exit)
+ ? Effect.sync(() => {
+ log.warn("background dependency install failed", { dir, error: String(exit.cause) })
+ })
+ : Effect.void,
+ ),
+ Effect.asVoid,
+ Effect.forkDetach,
+ )
+ deps.push(dep)
+
+ result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
+ result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
+ result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
+ // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
+ // returns normalized Specs and we only need to attach origin metadata here.
+ const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
+ yield* mergePluginOrigins(dir, list)
+ }
- const managedDir = ConfigManaged.managedConfigDir()
- if (existsSync(managedDir)) {
- for (const file of ["opencode.json", "opencode.jsonc"]) {
- const source = path.join(managedDir, file)
- yield* merge(source, yield* loadFile(source), "global")
+ if (process.env.OPENCODE_CONFIG_CONTENT) {
+ const source = "OPENCODE_CONFIG_CONTENT"
+ const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+ dir: ctx.directory,
+ source,
+ })
+ yield* merge(source, next, "local")
+ log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
- }
- // macOS managed preferences (.mobileconfig deployed via MDM) override everything
- const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
- if (managed) {
- result = mergeConfigConcatArrays(
- result,
- yield* loadConfig(managed.text, {
- dir: path.dirname(managed.source),
- source: managed.source,
- }),
+ const activeAccount = Option.getOrUndefined(
+ yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
- }
+ if (activeAccount?.active_org_id) {
+ const accountID = activeAccount.id
+ const orgID = activeAccount.active_org_id
+ const url = activeAccount.url
+ yield* Effect.gen(function* () {
+ const [configOpt, tokenOpt] = yield* Effect.all(
+ [accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
+ { concurrency: 2 },
+ )
+ if (Option.isSome(tokenOpt)) {
+ process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
+ yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
+ }
- for (const [name, mode] of Object.entries(result.mode ?? {})) {
- result.agent = mergeDeep(result.agent ?? {}, {
- [name]: {
- ...mode,
- mode: "primary" as const,
- },
- })
- }
+ if (Option.isSome(configOpt)) {
+ const source = `${url}/api/config`
+ const next = yield* loadConfig(JSON.stringify(configOpt.value), {
+ dir: path.dirname(source),
+ source,
+ })
+ for (const providerID of Object.keys(next.provider ?? {})) {
+ consoleManagedProviders.add(providerID)
+ }
+ yield* merge(source, next, "global")
+ }
+ }).pipe(
+ Effect.withSpan("Config.loadActiveOrgConfig"),
+ Effect.catch((err) => {
+ log.debug("failed to fetch remote account config", {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ return Effect.void
+ }),
+ )
+ }
- if (Flag.OPENCODE_PERMISSION) {
- result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
- }
+ const managedDir = ConfigManaged.managedConfigDir()
+ if (existsSync(managedDir)) {
+ for (const file of ["opencode.json", "opencode.jsonc"]) {
+ const source = path.join(managedDir, file)
+ yield* merge(source, yield* loadFile(source), "global")
+ }
+ }
+
+ // macOS managed preferences (.mobileconfig deployed via MDM) override everything
+ const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
+ if (managed) {
+ result = mergeConfigConcatArrays(
+ result,
+ yield* loadConfig(managed.text, {
+ dir: path.dirname(managed.source),
+ source: managed.source,
+ }),
+ )
+ }
+
+ for (const [name, mode] of Object.entries(result.mode ?? {})) {
+ result.agent = mergeDeep(result.agent ?? {}, {
+ [name]: {
+ ...mode,
+ mode: "primary" as const,
+ },
+ })
+ }
- if (result.tools) {
- const perms: Record<string, ConfigPermission.Action> = {}
- for (const [tool, enabled] of Object.entries(result.tools)) {
- const action: ConfigPermission.Action = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
- perms.edit = action
- continue
+ if (Flag.OPENCODE_PERMISSION) {
+ result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
+ }
+
+ if (result.tools) {
+ const perms: Record<string, ConfigPermission.Action> = {}
+ for (const [tool, enabled] of Object.entries(result.tools)) {
+ const action: ConfigPermission.Action = enabled ? "allow" : "deny"
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ perms.edit = action
+ continue
+ }
+ perms[tool] = action
}
- perms[tool] = action
+ result.permission = mergeDeep(perms, result.permission ?? {})
}
- result.permission = mergeDeep(perms, result.permission ?? {})
- }
- if (!result.username) result.username = os.userInfo().username
+ if (!result.username) result.username = os.userInfo().username
- if (result.autoshare === true && !result.share) {
- result.share = "auto"
- }
+ if (result.autoshare === true && !result.share) {
+ result.share = "auto"
+ }
- if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
- result.compaction = { ...result.compaction, auto: false }
- }
- if (Flag.OPENCODE_DISABLE_PRUNE) {
- result.compaction = { ...result.compaction, prune: false }
- }
+ if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
+ result.compaction = { ...result.compaction, auto: false }
+ }
+ if (Flag.OPENCODE_DISABLE_PRUNE) {
+ result.compaction = { ...result.compaction, prune: false }
+ }
- return {
- config: result,
- directories,
- deps,
- consoleState: {
- consoleManagedProviders: Array.from(consoleManagedProviders),
- activeOrgName,
- switchableOrgCount: 0,
- },
- }
- })
+ return {
+ config: result,
+ directories,
+ deps,
+ consoleState: {
+ consoleManagedProviders: Array.from(consoleManagedProviders),
+ activeOrgName,
+ switchableOrgCount: 0,
+ },
+ }
+ },
+ Effect.provideService(AppFileSystem.Service, fs),
+ )
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
- return yield* loadInstanceState(ctx)
+ return yield* loadInstanceState(ctx).pipe(Effect.orDie)
}),
)
diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts
index cf96a4e30..08668afe4 100644
--- a/packages/opencode/src/config/console-state.ts
+++ b/packages/opencode/src/config/console-state.ts
@@ -1,15 +1,16 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
-export const ConsoleState = z.object({
- consoleManagedProviders: z.array(z.string()),
- activeOrgName: z.string().optional(),
- switchableOrgCount: z.number().int().nonnegative(),
-})
-
-export type ConsoleState = z.infer<typeof ConsoleState>
+export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
+ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
+ activeOrgName: Schema.optional(Schema.String),
+ switchableOrgCount: Schema.Number,
+}) {
+ static readonly zod = zod(this)
+}
-export const emptyConsoleState: ConsoleState = {
+export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
-}
+})
diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts
index 93b87f028..8c1f09a24 100644
--- a/packages/opencode/src/config/formatter.ts
+++ b/packages/opencode/src/config/formatter.ts
@@ -1,13 +1,17 @@
export * as ConfigFormatter from "./formatter"
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export const Entry = z.object({
- disabled: z.boolean().optional(),
- command: z.array(z.string()).optional(),
- environment: z.record(z.string(), z.string()).optional(),
- extensions: z.array(z.string()).optional(),
-})
+export const Entry = Schema.Struct({
+ disabled: Schema.optional(Schema.Boolean),
+ command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+ extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
+ withStatics((s) => ({ zod: zod(s) })),
+)
+export type Info = Schema.Schema.Type<typeof Info>
diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts
index 8a22289d2..a84fc0b37 100644
--- a/packages/opencode/src/config/keybinds.ts
+++ b/packages/opencode/src/config/keybinds.ts
@@ -1,166 +1,127 @@
export * as ConfigKeybinds from "./keybinds"
-import z from "zod"
+import { Effect, Schema } from "effect"
+import type z from "zod"
+import { zod } from "@/util/effect-zod"
-export const Keybinds = z
- .object({
- leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
- app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
- editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
- theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
- sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
- scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
- username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
- status_view: z.string().optional().default("<leader>s").describe("View status"),
- session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
- session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
- session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
- session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
- session_fork: z.string().optional().default("none").describe("Fork session from message"),
- session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
- session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
- stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
- model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
- model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
- session_share: z.string().optional().default("none").describe("Share current session"),
- session_unshare: z.string().optional().default("none").describe("Unshare current session"),
- session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
- session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
- messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
- messages_page_down: z
- .string()
- .optional()
- .default("pagedown,ctrl+alt+f")
- .describe("Scroll messages down by one page"),
- messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
- messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
- messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
- messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"),
- messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
- messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
- messages_next: z.string().optional().default("none").describe("Navigate to next message"),
- messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
- messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
- messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
- messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
- messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
- messages_toggle_conceal: z
- .string()
- .optional()
- .default("<leader>h")
- .describe("Toggle code block concealment in messages"),
- tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
- model_list: z.string().optional().default("<leader>m").describe("List available models"),
- model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
- model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
- model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
- model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
- command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
- agent_list: z.string().optional().default("<leader>a").describe("List agents"),
- agent_cycle: z.string().optional().default("tab").describe("Next agent"),
- agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
- variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
- variant_list: z.string().optional().default("none").describe("List model variants"),
- input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
- input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
- input_submit: z.string().optional().default("return").describe("Submit input"),
- input_newline: z
- .string()
- .optional()
- .default("shift+return,ctrl+return,alt+return,ctrl+j")
- .describe("Insert newline in input"),
- input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
- input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
- input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
- input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
- input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
- input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
- input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
- input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
- input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
- input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
- input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"),
- input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
- input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
- input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
- input_select_visual_line_home: z
- .string()
- .optional()
- .default("alt+shift+a")
- .describe("Select to start of visual line in input"),
- input_select_visual_line_end: z
- .string()
- .optional()
- .default("alt+shift+e")
- .describe("Select to end of visual line in input"),
- input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
- input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
- input_select_buffer_home: z
- .string()
- .optional()
- .default("shift+home")
- .describe("Select to start of buffer in input"),
- input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
- input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
- input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
- input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
- input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
- input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
- input_undo: z
- .string()
- .optional()
- // On Windows prepend ctrl+z since terminal_suspend releases the binding.
- .default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
- .describe("Undo in input"),
- input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
- input_word_forward: z
- .string()
- .optional()
- .default("alt+f,alt+right,ctrl+right")
- .describe("Move word forward in input"),
- input_word_backward: z
- .string()
- .optional()
- .default("alt+b,alt+left,ctrl+left")
- .describe("Move word backward in input"),
- input_select_word_forward: z
- .string()
- .optional()
- .default("alt+shift+f,alt+shift+right")
- .describe("Select word forward in input"),
- input_select_word_backward: z
- .string()
- .optional()
- .default("alt+shift+b,alt+shift+left")
- .describe("Select word backward in input"),
- input_delete_word_forward: z
- .string()
- .optional()
- .default("alt+d,alt+delete,ctrl+delete")
- .describe("Delete word forward in input"),
- input_delete_word_backward: z
- .string()
- .optional()
- .default("ctrl+w,ctrl+backspace,alt+backspace")
- .describe("Delete word backward in input"),
- history_previous: z.string().optional().default("up").describe("Previous history item"),
- history_next: z.string().optional().default("down").describe("Next history item"),
- session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
- session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
- session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
- session_parent: z.string().optional().default("up").describe("Go to parent session"),
- terminal_suspend: z
- .string()
- .optional()
- .default("ctrl+z")
- .transform((v) => (process.platform === "win32" ? "none" : v))
- .describe("Suspend terminal"),
- terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
- tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
- plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
- display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
- })
- .strict()
- .meta({
- ref: "KeybindsConfig",
- })
+// Every keybind field has the same shape: an optional string with a default
+// binding and a human description. `keybind()` keeps the declaration list
+// below dense and readable.
+const keybind = (value: string, description: string) =>
+ Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
+
+// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
+// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
+const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
+
+const KeybindsSchema = Schema.Struct({
+ leader: keybind("ctrl+x", "Leader key for keybind combinations"),
+ app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
+ editor_open: keybind("<leader>e", "Open external editor"),
+ theme_list: keybind("<leader>t", "List available themes"),
+ sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
+ scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
+ username_toggle: keybind("none", "Toggle username visibility"),
+ status_view: keybind("<leader>s", "View status"),
+ session_export: keybind("<leader>x", "Export session to editor"),
+ session_new: keybind("<leader>n", "Create a new session"),
+ session_list: keybind("<leader>l", "List all sessions"),
+ session_timeline: keybind("<leader>g", "Show session timeline"),
+ session_fork: keybind("none", "Fork session from message"),
+ session_rename: keybind("ctrl+r", "Rename session"),
+ session_delete: keybind("ctrl+d", "Delete session"),
+ stash_delete: keybind("ctrl+d", "Delete stash entry"),
+ model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
+ model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
+ session_share: keybind("none", "Share current session"),
+ session_unshare: keybind("none", "Unshare current session"),
+ session_interrupt: keybind("escape", "Interrupt current session"),
+ session_compact: keybind("<leader>c", "Compact the session"),
+ messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
+ messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
+ messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
+ messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
+ messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
+ messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
+ messages_first: keybind("ctrl+g,home", "Navigate to first message"),
+ messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
+ messages_next: keybind("none", "Navigate to next message"),
+ messages_previous: keybind("none", "Navigate to previous message"),
+ messages_last_user: keybind("none", "Navigate to last user message"),
+ messages_copy: keybind("<leader>y", "Copy message"),
+ messages_undo: keybind("<leader>u", "Undo message"),
+ messages_redo: keybind("<leader>r", "Redo message"),
+ messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
+ tool_details: keybind("none", "Toggle tool details visibility"),
+ model_list: keybind("<leader>m", "List available models"),
+ model_cycle_recent: keybind("f2", "Next recently used model"),
+ model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
+ model_cycle_favorite: keybind("none", "Next favorite model"),
+ model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
+ command_list: keybind("ctrl+p", "List available commands"),
+ agent_list: keybind("<leader>a", "List agents"),
+ agent_cycle: keybind("tab", "Next agent"),
+ agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
+ variant_cycle: keybind("ctrl+t", "Cycle model variants"),
+ variant_list: keybind("none", "List model variants"),
+ input_clear: keybind("ctrl+c", "Clear input field"),
+ input_paste: keybind("ctrl+v", "Paste from clipboard"),
+ input_submit: keybind("return", "Submit input"),
+ input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
+ input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
+ input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
+ input_move_up: keybind("up", "Move cursor up in input"),
+ input_move_down: keybind("down", "Move cursor down in input"),
+ input_select_left: keybind("shift+left", "Select left in input"),
+ input_select_right: keybind("shift+right", "Select right in input"),
+ input_select_up: keybind("shift+up", "Select up in input"),
+ input_select_down: keybind("shift+down", "Select down in input"),
+ input_line_home: keybind("ctrl+a", "Move to start of line in input"),
+ input_line_end: keybind("ctrl+e", "Move to end of line in input"),
+ input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
+ input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
+ input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
+ input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
+ input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
+ input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
+ input_buffer_home: keybind("home", "Move to start of buffer in input"),
+ input_buffer_end: keybind("end", "Move to end of buffer in input"),
+ input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
+ input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
+ input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
+ input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
+ input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
+ input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
+ input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
+ input_undo: keybind(inputUndoDefault, "Undo in input"),
+ input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
+ input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
+ input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
+ input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
+ input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
+ input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
+ input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
+ history_previous: keybind("up", "Previous history item"),
+ history_next: keybind("down", "Next history item"),
+ session_child_first: keybind("<leader>down", "Go to first child session"),
+ session_child_cycle: keybind("right", "Go to next child session"),
+ session_child_cycle_reverse: keybind("left", "Go to previous child session"),
+ session_parent: keybind("up", "Go to parent session"),
+ // `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
+ // but `tui.ts` already forces the binding to "none" on win32 before calling
+ // `Keybinds.parse(...)`, so the schema-level transform was redundant.
+ terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
+ terminal_title_toggle: keybind("none", "Toggle terminal title"),
+ tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
+ plugin_manager: keybind("none", "Open plugin manager dialog"),
+ display_thinking: keybind("none", "Toggle thinking blocks visibility"),
+}).annotate({ identifier: "KeybindsConfig" })
+
+export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
+
+// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
+// which requires the runtime type to be a ZodObject, not just ZodType. Every
+// field is `string().optional().default(...)` at runtime, so widen to that.
+export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
+ Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
+>
diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts
new file mode 100644
index 000000000..49c34b663
--- /dev/null
+++ b/packages/opencode/src/config/layout.ts
@@ -0,0 +1,10 @@
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+
+export const Layout = Schema.Literals(["auto", "stretch"])
+ .annotate({ identifier: "LayoutConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Layout = Schema.Schema.Type<typeof Layout>
+
+export * as ConfigLayout from "./layout"
diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts
index 5530a5be5..1cf93177e 100644
--- a/packages/opencode/src/config/lsp.ts
+++ b/packages/opencode/src/config/lsp.ts
@@ -1,37 +1,45 @@
export * as ConfigLSP from "./lsp"
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
import * as LSPServer from "../lsp/server"
-export const Disabled = z.object({
- disabled: z.literal(true),
-})
+export const Disabled = Schema.Struct({
+ disabled: Schema.Literal(true),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export const Entry = z.union([
+export const Entry = Schema.Union([
Disabled,
- z.object({
- command: z.array(z.string()),
- extensions: z.array(z.string()).optional(),
- disabled: z.boolean().optional(),
- env: z.record(z.string(), z.string()).optional(),
- initialization: z.record(z.string(), z.any()).optional(),
+ Schema.Struct({
+ command: Schema.mutable(Schema.Array(Schema.String)),
+ extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ disabled: Schema.optional(Schema.Boolean),
+ env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+ initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
-])
+]).pipe(withStatics((s) => ({ zod: zod(s) })))
-export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
- (data) => {
- if (typeof data === "boolean") return true
- const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
+/**
+ * For custom (non-builtin) LSP server entries, `extensions` is required so the
+ * client knows which files the server should attach to. Builtin server IDs and
+ * explicitly disabled entries are exempt.
+ */
+export const requiresExtensionsForCustomServers = Schema.makeFilter<
+ boolean | Record<string, Schema.Schema.Type<typeof Entry>>
+>((data) => {
+ if (typeof data === "boolean") return undefined
+ const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
+ const ok = Object.entries(data).every(([id, config]) => {
+ if ("disabled" in config && config.disabled) return true
+ if (serverIds.has(id)) return true
+ return "extensions" in config && Boolean(config.extensions)
+ })
+ return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
+})
- return Object.entries(data).every(([id, config]) => {
- if (config.disabled) return true
- if (serverIds.has(id)) return true
- return Boolean(config.extensions)
- })
- },
- {
- error: "For custom LSP servers, 'extensions' array is required.",
- },
-)
+export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+ .check(requiresExtensionsForCustomServers)
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
-export type Info = z.infer<typeof Info>
+export type Info = Schema.Schema.Type<typeof Info>
diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts
index 5036cd6e4..8b77bc4c2 100644
--- a/packages/opencode/src/config/mcp.ts
+++ b/packages/opencode/src/config/mcp.ts
@@ -1,68 +1,62 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export const Local = z
- .object({
- type: z.literal("local").describe("Type of MCP server connection"),
- command: z.string().array().describe("Command and arguments to run the MCP server"),
- environment: z
- .record(z.string(), z.string())
- .optional()
- .describe("Environment variables to set when running the MCP server"),
- enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
- timeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
- })
- .strict()
- .meta({
- ref: "McpLocalConfig",
- })
+export class Local extends Schema.Class<Local>("McpLocalConfig")({
+ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
+ command: Schema.mutable(Schema.Array(Schema.String)).annotate({
+ description: "Command and arguments to run the MCP server",
+ }),
+ environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
+ description: "Environment variables to set when running the MCP server",
+ }),
+ enabled: Schema.optional(Schema.Boolean).annotate({
+ description: "Enable or disable the MCP server on startup",
+ }),
+ timeout: Schema.optional(Schema.Number).annotate({
+ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
+ }),
+}) {
+ static readonly zod = zod(this)
+}
-export const OAuth = z
- .object({
- clientId: z
- .string()
- .optional()
- .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
- clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
- scope: z.string().optional().describe("OAuth scopes to request during authorization"),
- redirectUri: z
- .string()
- .optional()
- .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
- })
- .strict()
- .meta({
- ref: "McpOAuthConfig",
- })
-export type OAuth = z.infer<typeof OAuth>
+export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
+ clientId: Schema.optional(Schema.String).annotate({
+ description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
+ }),
+ clientSecret: Schema.optional(Schema.String).annotate({
+ description: "OAuth client secret (if required by the authorization server)",
+ }),
+ scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
+ redirectUri: Schema.optional(Schema.String).annotate({
+ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
+ }),
+}) {
+ static readonly zod = zod(this)
+}
-export const Remote = z
- .object({
- type: z.literal("remote").describe("Type of MCP server connection"),
- url: z.string().describe("URL of the remote MCP server"),
- enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
- headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
- oauth: z
- .union([OAuth, z.literal(false)])
- .optional()
- .describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
- timeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
- })
- .strict()
- .meta({
- ref: "McpRemoteConfig",
- })
+export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
+ type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
+ url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
+ enabled: Schema.optional(Schema.Boolean).annotate({
+ description: "Enable or disable the MCP server on startup",
+ }),
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
+ description: "Headers to send with the request",
+ }),
+ oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
+ description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
+ }),
+ timeout: Schema.optional(Schema.Number).annotate({
+ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
+ }),
+}) {
+ static readonly zod = zod(this)
+}
-export const Info = z.discriminatedUnion("type", [Local, Remote])
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Union([Local, Remote])
+ .annotate({ discriminator: "type" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigMCP from "./mcp"
diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts
index 909e9aa92..3ad9e035c 100644
--- a/packages/opencode/src/config/model-id.ts
+++ b/packages/opencode/src/config/model-id.ts
@@ -1,3 +1,14 @@
+import { Schema } from "effect"
import z from "zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
+// The original Zod schema carried an external $ref pointing at the models.dev
+// JSON schema. That external reference is not a named SDK component — it is a
+// literal pointer to an outside schema — so the walker cannot re-derive it
+// from AST metadata. Preserve the exact original Zod via ZodOverride.
+export const ConfigModelID = Schema.String.annotate({
+ [ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
+
+export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index dcf0c940f..db4b914f7 100644
--- a/packages/opencode/src/config/paths.ts
+++ b/packages/opencode/src/config/paths.ts
@@ -6,33 +6,41 @@ import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { unique } from "remeda"
import { JsonError } from "./error"
+import * as Effect from "effect/Effect"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-export async function projectFiles(name: string, directory: string, worktree?: string) {
- return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
-}
+export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
+ name: string,
+ directory: string,
+ worktree?: string,
+) {
+ const afs = yield* AppFileSystem.Service
+ return (yield* afs.up({
+ targets: [`${name}.jsonc`, `${name}.json`],
+ start: directory,
+ stop: worktree,
+ })).toReversed()
+})
-export async function directories(directory: string, worktree?: string) {
+export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
+ const afs = yield* AppFileSystem.Service
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
- ? await Array.fromAsync(
- Filesystem.up({
- targets: [".opencode"],
- start: directory,
- stop: worktree,
- }),
- )
+ ? yield* afs.up({
+ targets: [".opencode"],
+ start: directory,
+ stop: worktree,
+ })
: []),
- ...(await Array.fromAsync(
- Filesystem.up({
- targets: [".opencode"],
- start: Global.Path.home,
- stop: Global.Path.home,
- }),
- )),
+ ...(yield* afs.up({
+ targets: [".opencode"],
+ start: Global.Path.home,
+ stop: Global.Path.home,
+ })),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
-}
+})
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts
index af01f6f2a..d4883ed8c 100644
--- a/packages/opencode/src/config/permission.ts
+++ b/packages/opencode/src/config/permission.ts
@@ -1,6 +1,28 @@
export * as ConfigPermission from "./permission"
-import z from "zod"
+import { Schema } from "effect"
+import { zod, ZodPreprocess } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
+export const Action = Schema.Literals(["ask", "allow", "deny"])
+ .annotate({ identifier: "PermissionActionConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Action = Schema.Schema.Type<typeof Action>
+
+export const Object = Schema.Record(Schema.String, Action)
+ .annotate({ identifier: "PermissionObjectConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Object = Schema.Schema.Type<typeof Object>
+
+export const Rule = Schema.Union([Action, Object])
+ .annotate({ identifier: "PermissionRuleConfig" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Rule = Schema.Schema.Type<typeof Rule>
+
+// Captures the user's original property insertion order before Schema.Struct
+// canonicalises the object. See the `ZodPreprocess` comment in
+// `util/effect-zod.ts` for the full rationale — in short: rule precedence is
+// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
+// and `Schema.StructWithRest` would otherwise drop that order.
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __originalKeys: globalThis.Object.keys(val), ...val }
@@ -8,21 +30,36 @@ const permissionPreprocess = (val: unknown) => {
return val
}
-export const Action = z.enum(["ask", "allow", "deny"]).meta({
- ref: "PermissionActionConfig",
-})
-export type Action = z.infer<typeof Action>
-
-export const Object = z.record(z.string(), Action).meta({
- ref: "PermissionObjectConfig",
-})
-export type Object = z.infer<typeof Object>
+const ObjectShape = Schema.StructWithRest(
+ Schema.Struct({
+ __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ read: Schema.optional(Rule),
+ edit: Schema.optional(Rule),
+ glob: Schema.optional(Rule),
+ grep: Schema.optional(Rule),
+ list: Schema.optional(Rule),
+ bash: Schema.optional(Rule),
+ task: Schema.optional(Rule),
+ external_directory: Schema.optional(Rule),
+ todowrite: Schema.optional(Action),
+ question: Schema.optional(Action),
+ webfetch: Schema.optional(Action),
+ websearch: Schema.optional(Action),
+ codesearch: Schema.optional(Action),
+ lsp: Schema.optional(Rule),
+ doom_loop: Schema.optional(Action),
+ skill: Schema.optional(Rule),
+ }),
+ [Schema.Record(Schema.String, Rule)],
+)
-export const Rule = z.union([Action, Object]).meta({
- ref: "PermissionRuleConfig",
+const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
+ [ZodPreprocess]: permissionPreprocess,
})
-export type Rule = z.infer<typeof Rule>
+// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
+// user's original insertion order. A plain string input (the Action branch of
+// the union) becomes `{ "*": action }`.
const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
@@ -35,34 +72,5 @@ const transform = (x: unknown): Record<string, Rule> => {
return result
}
-export const Info = z
- .preprocess(
- permissionPreprocess,
- z
- .object({
- __originalKeys: z.string().array().optional(),
- read: Rule.optional(),
- edit: Rule.optional(),
- glob: Rule.optional(),
- grep: Rule.optional(),
- list: Rule.optional(),
- bash: Rule.optional(),
- task: Rule.optional(),
- external_directory: Rule.optional(),
- todowrite: Action.optional(),
- question: Action.optional(),
- webfetch: Action.optional(),
- websearch: Action.optional(),
- codesearch: Action.optional(),
- lsp: Rule.optional(),
- doom_loop: Action.optional(),
- skill: Rule.optional(),
- })
- .catchall(Rule)
- .or(Action),
- )
- .transform(transform)
- .meta({
- ref: "PermissionConfig",
- })
-export type Info = z.infer<typeof Info>
+export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
+export type Info = Record<string, Rule>
diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts
index 7d335bcc5..4277c1cd6 100644
--- a/packages/opencode/src/config/plugin.ts
+++ b/packages/opencode/src/config/plugin.ts
@@ -1,16 +1,20 @@
import { Glob } from "@opencode-ai/shared/util/glob"
-import z from "zod"
+import { Schema } from "effect"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
import path from "path"
-const Options = z.record(z.string(), z.unknown())
-export type Options = z.infer<typeof Options>
+export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Options = Schema.Schema.Type<typeof Options>
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
// It answers "what should we load?" but says nothing about where that value came from.
-export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
-export type Spec = z.infer<typeof Spec>
+export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
+ withStatics((s) => ({ zod: zod(s) })),
+)
+export type Spec = Schema.Schema.Type<typeof Spec>
export type Scope = "global" | "local"
diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts
index 877677519..212e71625 100644
--- a/packages/opencode/src/config/provider.ts
+++ b/packages/opencode/src/config/provider.ts
@@ -1,120 +1,114 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export const Model = z
- .object({
- id: z.string(),
- name: z.string(),
- family: z.string().optional(),
- release_date: z.string(),
- attachment: z.boolean(),
- reasoning: z.boolean(),
- temperature: z.boolean(),
- tool_call: z.boolean(),
- interleaved: z
- .union([
- z.literal(true),
- z
- .object({
- field: z.enum(["reasoning_content", "reasoning_details"]),
- })
- .strict(),
- ])
- .optional(),
- cost: z
- .object({
- input: z.number(),
- output: z.number(),
- cache_read: z.number().optional(),
- cache_write: z.number().optional(),
- context_over_200k: z
- .object({
- input: z.number(),
- output: z.number(),
- cache_read: z.number().optional(),
- cache_write: z.number().optional(),
- })
- .optional(),
- })
- .optional(),
- limit: z.object({
- context: z.number(),
- input: z.number().optional(),
- output: z.number(),
+const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
+
+export const Model = Schema.Struct({
+ id: Schema.optional(Schema.String),
+ name: Schema.optional(Schema.String),
+ family: Schema.optional(Schema.String),
+ release_date: Schema.optional(Schema.String),
+ attachment: Schema.optional(Schema.Boolean),
+ reasoning: Schema.optional(Schema.Boolean),
+ temperature: Schema.optional(Schema.Boolean),
+ tool_call: Schema.optional(Schema.Boolean),
+ interleaved: Schema.optional(
+ Schema.Union([
+ Schema.Literal(true),
+ Schema.Struct({
+ field: Schema.Literals(["reasoning_content", "reasoning_details"]),
+ }),
+ ]),
+ ),
+ cost: Schema.optional(
+ Schema.Struct({
+ input: Schema.Number,
+ output: Schema.Number,
+ cache_read: Schema.optional(Schema.Number),
+ cache_write: Schema.optional(Schema.Number),
+ context_over_200k: Schema.optional(
+ Schema.Struct({
+ input: Schema.Number,
+ output: Schema.Number,
+ cache_read: Schema.optional(Schema.Number),
+ cache_write: Schema.optional(Schema.Number),
+ }),
+ ),
+ }),
+ ),
+ limit: Schema.optional(
+ Schema.Struct({
+ context: Schema.Number,
+ input: Schema.optional(Schema.Number),
+ output: Schema.Number,
}),
- modalities: z
- .object({
- input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
- output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
- })
- .optional(),
- experimental: z.boolean().optional(),
- status: z.enum(["alpha", "beta", "deprecated"]).optional(),
- provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
- options: z.record(z.string(), z.any()),
- headers: z.record(z.string(), z.string()).optional(),
- variants: z
- .record(
- z.string(),
- z
- .object({
- disabled: z.boolean().optional().describe("Disable this variant for the model"),
- })
- .catchall(z.any()),
- )
- .optional()
- .describe("Variant-specific configuration"),
- })
- .partial()
+ ),
+ modalities: Schema.optional(
+ Schema.Struct({
+ input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
+ output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
+ }),
+ ),
+ experimental: Schema.optional(Schema.Boolean),
+ status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
+ provider: Schema.optional(
+ Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
+ ),
+ options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+ variants: Schema.optional(
+ Schema.Record(
+ Schema.String,
+ Schema.StructWithRest(
+ Schema.Struct({
+ disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
+ }),
+ [Schema.Record(Schema.String, Schema.Any)],
+ ),
+ ).annotate({ description: "Variant-specific configuration" }),
+ ),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export const Info = z
- .object({
- api: z.string().optional(),
- name: z.string(),
- env: z.array(z.string()),
- id: z.string(),
- npm: z.string().optional(),
- whitelist: z.array(z.string()).optional(),
- blacklist: z.array(z.string()).optional(),
- options: z
- .object({
- apiKey: z.string().optional(),
- baseURL: z.string().optional(),
- enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
- setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
- timeout: z
- .union([
- z
- .number()
- .int()
- .positive()
- .describe(
- "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
- ),
- z.literal(false).describe("Disable timeout for this provider entirely."),
- ])
- .optional()
- .describe(
+export class Info extends Schema.Class<Info>("ProviderConfig")({
+ api: Schema.optional(Schema.String),
+ name: Schema.optional(Schema.String),
+ env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ id: Schema.optional(Schema.String),
+ npm: Schema.optional(Schema.String),
+ whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ options: Schema.optional(
+ Schema.StructWithRest(
+ Schema.Struct({
+ apiKey: Schema.optional(Schema.String),
+ baseURL: Schema.optional(Schema.String),
+ enterpriseUrl: Schema.optional(Schema.String).annotate({
+ description: "GitHub Enterprise URL for copilot authentication",
+ }),
+ setCacheKey: Schema.optional(Schema.Boolean).annotate({
+ description: "Enable promptCacheKey for this provider (default false)",
+ }),
+ timeout: Schema.optional(
+ Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
+ description:
+ "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
+ }),
+ ).annotate({
+ description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
- ),
- chunkTimeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe(
+ }),
+ chunkTimeout: Schema.optional(PositiveInt).annotate({
+ description:
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
- ),
- })
- .catchall(z.any())
- .optional(),
- models: z.record(z.string(), Model).optional(),
- })
- .partial()
- .strict()
- .meta({
- ref: "ProviderConfig",
- })
-
-export type Info = z.infer<typeof Info>
+ }),
+ }),
+ [Schema.Record(Schema.String, Schema.Any)],
+ ),
+ ),
+ models: Schema.optional(Schema.Record(Schema.String, Model)),
+}) {
+ static readonly zod = zod(this)
+}
export * as ConfigProvider from "./provider"
diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts
new file mode 100644
index 000000000..969a79964
--- /dev/null
+++ b/packages/opencode/src/config/server.ts
@@ -0,0 +1,20 @@
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+
+export class Server extends Schema.Class<Server>("ServerConfig")({
+ port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
+ description: "Port to listen on",
+ }),
+ hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
+ mdns: Schema.optional(Schema.Boolean).annotate({ description: "Enable mDNS service discovery" }),
+ mdnsDomain: Schema.optional(Schema.String).annotate({
+ description: "Custom domain name for mDNS service (default: opencode.local)",
+ }),
+ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
+ description: "Additional domains to allow for CORS",
+ }),
+}) {
+ static readonly zod = zod(this)
+}
+
+export * as ConfigServer from "./server"
diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts
index 38cbf99e7..f29d854f5 100644
--- a/packages/opencode/src/config/skills.ts
+++ b/packages/opencode/src/config/skills.ts
@@ -1,13 +1,16 @@
-import z from "zod"
+import { Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
-export const Info = z.object({
- paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
- urls: z
- .array(z.string())
- .optional()
- .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
-})
+export const Info = Schema.Struct({
+ paths: Schema.optional(Schema.Array(Schema.String)).annotate({
+ description: "Additional paths to skill folders",
+ }),
+ urls: Schema.optional(Schema.Array(Schema.String)).annotate({
+ description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
+ }),
+}).pipe(withStatics((s) => ({ zod: zod(s) })))
-export type Info = z.infer<typeof Info>
+export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigSkills from "./skills"
diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts
index 3961cd0e2..07acd5ce5 100644
--- a/packages/opencode/src/control-plane/types.ts
+++ b/packages/opencode/src/control-plane/types.ts
@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
- create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void>
+ create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}
diff --git a/packages/opencode/src/control-plane/workspace-context.ts b/packages/opencode/src/control-plane/workspace-context.ts
index 3d4fa5bae..85ef596e7 100644
--- a/packages/opencode/src/control-plane/workspace-context.ts
+++ b/packages/opencode/src/control-plane/workspace-context.ts
@@ -2,13 +2,13 @@ import { LocalContext } from "../util"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
- workspaceID: WorkspaceID
+ workspaceID: WorkspaceID | undefined
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
- async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
+ async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
},
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index 3af11707e..eb689df02 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/packages/opencode/src/control-plane/workspace.ts
@@ -7,7 +7,7 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
-import { EventTable } from "@/sync/event.sql"
+import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { Flag } from "@/flag/flag"
import { Log } from "@/util"
import { Filesystem } from "@/util"
@@ -23,8 +23,8 @@ import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
-import { EventSequenceTable } from "@/sync/event.sql"
import { waitEvent } from "./util"
+import { WorkspaceContext } from "./workspace-context"
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
@@ -34,7 +34,6 @@ export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
- error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
@@ -116,6 +115,9 @@ export const create = fn(CreateInput, async (input) => {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
+ OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
+ OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
+ OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
await adaptor.create(config, env)
@@ -298,22 +300,13 @@ export function list(project: Project.Info) {
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
-
- for (const space of spaces) startSync(space)
return spaces
}
-function lookup(id: WorkspaceID) {
+export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
-}
-
-export const get = fn(WorkspaceID.zod, async (id) => {
- const space = lookup(id)
- if (!space) return
- startSync(space)
- return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
@@ -345,10 +338,10 @@ const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
-function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
+function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) {
const prev = connections.get(id)
- if (prev?.status === status && prev?.error === error) return
- const next = { workspaceID: id, status, error }
+ if (prev?.status === status) return
+ const next = { workspaceID: id, status }
connections.set(id, next)
if (status === "error") {
@@ -425,68 +418,145 @@ function route(url: string | URL, path: string) {
return next
}
-async function syncWorkspace(space: Info, signal: AbortSignal) {
- while (!signal.aborted) {
- log.info("connecting to global sync", { workspace: space.name })
- setStatus(space.id, "connecting")
+async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
+ const res = await fetch(route(url, "/global/event"), {
+ method: "GET",
+ headers,
+ signal,
+ })
- const adaptor = await getAdaptor(space.projectID, space.type)
- const target = await adaptor.target(space)
+ if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`)
+ if (!res.body) throw new Error("No response body from global sync")
- if (target.type === "local") return
+ return res.body
+}
- const res = await fetch(route(target.url, "/global/event"), {
- method: "GET",
- headers: target.headers,
- signal,
- }).catch((err: unknown) => {
- setStatus(space.id, "error", err instanceof Error ? err.message : String(err))
+async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
+ const sessionIDs = Database.use((db) =>
+ db
+ .select({ id: SessionTable.id })
+ .from(SessionTable)
+ .where(eq(SessionTable.workspace_id, space.id))
+ .all()
+ .map((row) => row.id),
+ )
+ const state = sessionIDs.length
+ ? Object.fromEntries(
+ Database.use((db) =>
+ db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
+ ).map((row) => [row.aggregate_id, row.seq]),
+ )
+ : {}
+
+ log.info("syncing workspace history", {
+ workspaceID: space.id,
+ sessions: sessionIDs.length,
+ known: Object.keys(state).length,
+ })
+
+ const requestHeaders = new Headers(headers)
+ requestHeaders.set("content-type", "application/json")
+
+ const res = await fetch(route(url, "/sync/history"), {
+ method: "POST",
+ headers: requestHeaders,
+ body: JSON.stringify(state),
+ signal,
+ })
+
+ if (!res.ok) {
+ const body = await res.text()
+ throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`)
+ }
+
+ const events = await res.json()
+
+ return WorkspaceContext.provide({
+ workspaceID: space.id,
+ fn: () => {
+ for (const event of events) {
+ SyncEvent.replay(
+ {
+ id: event.id,
+ aggregateID: event.aggregate_id,
+ seq: event.seq,
+ type: event.type,
+ data: event.data,
+ },
+ { publish: true },
+ )
+ }
+ },
+ })
+
+ log.info("workspace history synced", {
+ workspaceID: space.id,
+ events: events.length,
+ })
+}
+async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
+ const adaptor = await getAdaptor(space.projectID, space.type)
+ const target = await adaptor.target(space)
+
+ if (target.type === "local") return null
+
+ let attempt = 0
+
+ while (!signal.aborted) {
+ log.info("connecting to global sync", { workspace: space.name })
+ setStatus(space.id, "connecting")
+
+ let stream
+ try {
+ stream = await connectSSE(target.url, target.headers, signal)
+ await syncHistory(space, target.url, target.headers, signal)
+ } catch (err) {
+ stream = null
+ setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
- error: err,
+ err,
})
- return undefined
- })
-
- if (!res || !res.ok || !res.body) {
- const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}`
- log.info("failed to connect to global sync", { workspace: space.name, error })
- setStatus(space.id, "error", error)
- await sleep(1000)
- continue
}
- log.info("global sync connected", { workspace: space.name })
- setStatus(space.id, "connected")
+ if (stream) {
+ attempt = 0
- await parseSSE(res.body, signal, (evt: any) => {
- try {
- if (!("payload" in evt)) return
+ log.info("global sync connected", { workspace: space.name })
+ setStatus(space.id, "connected")
- if (evt.payload.type === "sync") {
- SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
- }
+ await parseSSE(stream, signal, (evt: any) => {
+ try {
+ if (!("payload" in evt)) return
+ if (evt.payload.type === "server.heartbeat") return
- GlobalBus.emit("event", {
- directory: evt.directory,
- project: evt.project,
- workspace: space.id,
- payload: evt.payload,
- })
- } catch (err) {
- log.info("failed to replay global event", {
- workspaceID: space.id,
- error: err,
- })
- }
- })
+ if (evt.payload.type === "sync") {
+ SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
+ }
+
+ GlobalBus.emit("event", {
+ directory: evt.directory,
+ project: evt.project,
+ workspace: space.id,
+ payload: evt.payload,
+ })
+ } catch (err) {
+ log.info("failed to replay global event", {
+ workspaceID: space.id,
+ error: err,
+ })
+ }
+ })
- log.info("disconnected from global sync: " + space.id)
- setStatus(space.id, "disconnected")
+ log.info("disconnected from global sync: " + space.id)
+ setStatus(space.id, "disconnected")
+ }
- // TODO: Implement exponential backoff
- await sleep(1000)
+ // Back off reconnect attempts up to 2 minutes while the workspace
+ // stays unavailable.
+ await sleep(Math.min(120_000, 1_000 * 2 ** attempt))
+ attempt += 1
}
}
@@ -498,7 +568,7 @@ async function startSync(space: Info) {
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
- setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
+ setStatus(space.id, exists ? "connected" : "error")
})
return
}
@@ -510,10 +580,10 @@ async function startSync(space: Info) {
const abort = new AbortController()
aborts.set(space.id, abort)
- void syncWorkspace(space, abort.signal).catch((error) => {
+ void syncWorkspaceLoop(space, abort.signal).catch((error) => {
aborts.delete(space.id)
- setStatus(space.id, "error", String(error))
+ setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
@@ -527,4 +597,19 @@ function stopSync(id: WorkspaceID) {
connections.delete(id)
}
+export function startWorkspaceSyncing(projectID: ProjectID) {
+ const spaces = Database.use((db) =>
+ db
+ .select({ workspace: WorkspaceTable })
+ .from(WorkspaceTable)
+ .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
+ .where(eq(WorkspaceTable.project_id, projectID))
+ .all(),
+ )
+
+ for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) {
+ void startSync(fromRow(row))
+ }
+}
+
export * as Workspace from "./workspace"
diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts
index a9ed95774..d68e00a32 100644
--- a/packages/opencode/src/effect/app-runtime.ts
+++ b/packages/opencode/src/effect/app-runtime.ts
@@ -1,5 +1,5 @@
import { Layer, ManagedRuntime } from "effect"
-import { attach, memoMap } from "./run-service"
+import { attach } from "./run-service"
import * as Observability from "./observability"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -9,7 +9,6 @@ import { Account } from "@/account/account"
import { Config } from "@/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
-import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage"
@@ -47,7 +46,8 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share"
import { SessionShare } from "@/share"
-import { Npm } from "@opencode-ai/shared/npm"
+import { Npm } from "@/npm"
+import { memoMap } from "./memo-map"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -58,7 +58,6 @@ export const AppLayer = Layer.mergeAll(
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
- FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts
index 62b71e58b..37698c43a 100644
--- a/packages/opencode/src/effect/bootstrap-runtime.ts
+++ b/packages/opencode/src/effect/bootstrap-runtime.ts
@@ -1,5 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
-import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
@@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Config } from "@/config"
import * as Observability from "./observability"
+import { memoMap } from "./memo-map"
export const BootstrapLayer = Layer.mergeAll(
Config.defaultLayer,
diff --git a/packages/opencode/src/effect/logger.ts b/packages/opencode/src/effect/logger.ts
index 21e0fc43a..0e58b8acb 100644
--- a/packages/opencode/src/effect/logger.ts
+++ b/packages/opencode/src/effect/logger.ts
@@ -3,6 +3,8 @@ import { Log } from "@/util"
type Fields = Record<string, unknown>
+const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
+
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
@@ -12,7 +14,11 @@ export interface Handle {
}
const clean = (input?: Fields): Fields =>
- Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
+ Object.fromEntries(
+ Object.entries(input ?? {})
+ .filter((entry) => entry[1] !== undefined && entry[1] !== null)
+ .map(([key, value]) => [normalizeKey(key), value]),
+ )
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
diff --git a/packages/opencode/src/effect/memo-map.ts b/packages/opencode/src/effect/memo-map.ts
new file mode 100644
index 000000000..c797dbf42
--- /dev/null
+++ b/packages/opencode/src/effect/memo-map.ts
@@ -0,0 +1,3 @@
+import { Layer } from "effect"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
diff --git a/packages/opencode/src/effect/observability.ts b/packages/opencode/src/effect/observability.ts
index efd16ffc0..fb81d5f5b 100644
--- a/packages/opencode/src/effect/observability.ts
+++ b/packages/opencode/src/effect/observability.ts
@@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
+import { ensureProcessMetadata } from "@/util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
+const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@@ -19,26 +21,51 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
)
: undefined
-const resource = {
- serviceName: "opencode",
- serviceVersion: InstallationVersion,
- attributes: {
- "deployment.environment.name": InstallationChannel,
- "opencode.client": Flag.OPENCODE_CLIENT,
- },
+export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
+ const processMetadata = ensureProcessMetadata("main")
+ const attributes: Record<string, string> = (() => {
+ const value = process.env.OTEL_RESOURCE_ATTRIBUTES
+ if (!value) return {}
+ try {
+ return Object.fromEntries(
+ value.split(",").map((entry) => {
+ const index = entry.indexOf("=")
+ if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
+ return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
+ }),
+ )
+ } catch {
+ return {}
+ }
+ })()
+
+ return {
+ serviceName: "opencode",
+ serviceVersion: InstallationVersion,
+ attributes: {
+ ...attributes,
+ "deployment.environment.name": InstallationChannel,
+ "opencode.client": Flag.OPENCODE_CLIENT,
+ "opencode.process_role": processMetadata.processRole,
+ "opencode.run_id": processMetadata.runID,
+ "service.instance.id": processID,
+ },
+ }
}
-const logs = Logger.layer(
- [
- EffectLogger.logger,
- OtlpLogger.make({
- url: `${base}/v1/logs`,
- resource,
- headers,
- }),
- ],
- { mergeWithExisting: false },
-).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
+function logs() {
+ return Logger.layer(
+ [
+ EffectLogger.logger,
+ OtlpLogger.make({
+ url: `${base}/v1/logs`,
+ resource: resource(),
+ headers,
+ }),
+ ],
+ { mergeWithExisting: false },
+ ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
+}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
@@ -58,7 +85,7 @@ const traces = async () => {
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
- resource,
+ resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
@@ -73,7 +100,7 @@ export const layer = !base
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
- return Layer.mergeAll(trace, logs)
+ return Layer.mergeAll(trace, logs())
}),
)
diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts
index 28265f9b2..98ff83ea5 100644
--- a/packages/opencode/src/effect/run-service.ts
+++ b/packages/opencode/src/effect/run-service.ts
@@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"
-
-export const memoMap = Layer.makeMemoMapUnsafe()
+import { memoMap } from "./memo-map"
type Refs = {
instance?: InstanceContext
diff --git a/packages/opencode/src/cli/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
index 57b9f8ede..ad7872f0b 100644
--- a/packages/opencode/src/cli/effect/runtime.ts
+++ b/packages/opencode/src/effect/runtime.ts
@@ -1,7 +1,6 @@
-import { Observability } from "@/effect/observability"
+import { Observability } from "./observability"
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
-
-export const memoMap = Layer.makeMemoMapUnsafe()
+import { memoMap } from "./memo-map"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 2f30b5400..af4fbf76c 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -356,8 +356,9 @@ export const layer = Layer.effect(
)
const scan = Effect.fn("File.scan")(function* () {
- if (Instance.directory === path.parse(Instance.directory).root) return
- const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
+ const ctx = yield* InstanceState.context
+ if (ctx.directory === path.parse(ctx.directory).root) return
+ const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global"
const next: Entry = { files: [], dirs: [] }
if (isGlobalHome) {
@@ -366,14 +367,14 @@ export const layer = Layer.effect(
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
- const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
+ const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => []))
for (const entry of top) {
if (entry.type !== "directory") continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
- const base = path.join(Instance.directory, entry.name)
+ const base = path.join(ctx.directory, entry.name)
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
for (const child of children) {
if (child.type !== "directory") continue
@@ -384,7 +385,7 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
- const files = yield* rg.files({ cwd: Instance.directory }).pipe(
+ const files = yield* rg.files({ cwd: ctx.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
@@ -416,7 +417,7 @@ export const layer = Layer.effect(
})
const gitText = Effect.fnUntraced(function* (args: string[]) {
- return (yield* git.run(args, { cwd: Instance.directory })).text()
+ return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text()
})
const init = Effect.fn("File.init")(function* () {
@@ -424,7 +425,8 @@ export const layer = Layer.effect(
})
const status = Effect.fn("File.status")(function* () {
- if (Instance.project.vcs !== "git") return []
+ const ctx = yield* InstanceState.context
+ if (ctx.project.vcs !== "git") return []
const diffOutput = yield* gitText([
"-c",
@@ -463,7 +465,7 @@ export const layer = Layer.effect(
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs
- .readFileString(path.join(Instance.directory, file))
+ .readFileString(path.join(ctx.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({
@@ -498,19 +500,22 @@ export const layer = Layer.effect(
}
return changed.map((item) => {
- const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
+ const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path)
return {
...item,
- path: path.relative(Instance.directory, full),
+ path: path.relative(ctx.directory, full),
}
})
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
- const full = path.join(Instance.directory, file)
+ const ctx = yield* InstanceState.context
+ const full = path.join(ctx.directory, file)
- if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
+ if (!Instance.containsPath(full, ctx)) {
+ throw new Error("Access denied: path escapes project directory")
+ }
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
@@ -553,13 +558,13 @@ export const layer = Layer.effect(
Effect.catch(() => Effect.succeed("")),
)
- if (Instance.project.vcs === "git") {
+ if (ctx.project.vcs === "git") {
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
- const original = yield* git.show(Instance.directory, "HEAD", file)
+ const original = yield* git.show(ctx.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -573,21 +578,24 @@ export const layer = Layer.effect(
})
const list = Effect.fn("File.list")(function* (dir?: string) {
+ const ctx = yield* InstanceState.context
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
- if (Instance.project.vcs === "git") {
+ if (ctx.project.vcs === "git") {
const ig = ignore()
- const gitignore = path.join(Instance.project.worktree, ".gitignore")
+ const gitignore = path.join(ctx.worktree, ".gitignore")
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
if (gitignoreText) ig.add(gitignoreText)
- const ignoreFile = path.join(Instance.project.worktree, ".ignore")
+ const ignoreFile = path.join(ctx.worktree, ".ignore")
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
if (ignoreText) ig.add(ignoreText)
ignored = ig.ignores.bind(ig)
}
- const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
- if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
+ const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
+ if (!Instance.containsPath(resolved, ctx)) {
+ throw new Error("Access denied: path escapes project directory")
+ }
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
@@ -595,7 +603,7 @@ export const layer = Layer.effect(
for (const entry of entries) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
- const file = path.relative(Instance.directory, absolute)
+ const file = path.relative(ctx.directory, absolute)
const type = entry.type === "directory" ? "directory" : "file"
nodes.push({
name: entry.name,
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index ac450108e..c84d9b522 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -1,14 +1,28 @@
-import fs from "fs/promises"
import path from "path"
-import { fileURLToPath } from "url"
import z from "zod"
-import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
-import { ripgrep } from "ripgrep"
-
-import { Filesystem } from "@/util"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect"
+import type { PlatformError } from "effect/PlatformError"
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Global } from "@/global"
import { Log } from "@/util"
+import { sanitizedProcessEnv } from "@/util/opencode-process"
+import { which } from "@/util/which"
const log = Log.create({ service: "ripgrep" })
+const VERSION = "14.1.1"
+const PLATFORM = {
+ "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
+ "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
+ "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
+ "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
+ "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
+ "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
+} as const
const Stats = z.object({
elapsed: z.object({
@@ -120,64 +134,20 @@ export interface TreeInput {
}
export interface Interface {
- readonly files: (input: FilesInput) => Stream.Stream<string, Error>
- readonly tree: (input: TreeInput) => Effect.Effect<string, Error>
- readonly search: (input: SearchInput) => Effect.Effect<SearchResult, Error>
+ readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
+ readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
+ readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
-type Run = { kind: "files" | "search"; cwd: string; args: string[] }
-
-type WorkerResult = {
- type: "result"
- code: number
- stdout: string
- stderr: string
-}
-
-type WorkerLine = {
- type: "line"
- line: string
-}
-
-type WorkerDone = {
- type: "done"
- code: number
- stderr: string
-}
-
-type WorkerError = {
- type: "error"
- error: {
- message: string
- name?: string
- stack?: string
- }
-}
-
function env() {
- const env = Object.fromEntries(
- Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
- )
+ const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
-function text(input: unknown) {
- if (typeof input === "string") return input
- if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
- if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
- return String(input)
-}
-
-function toError(input: unknown) {
- if (input instanceof Error) return input
- if (typeof input === "string") return new Error(input)
- return new Error(String(input))
-}
-
-function abort(signal?: AbortSignal) {
+function aborted(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
@@ -185,6 +155,16 @@ function abort(signal?: AbortSignal) {
return out
}
+function waitForAbort(signal?: AbortSignal) {
+ if (!signal) return Effect.never
+ if (signal.aborted) return Effect.fail(aborted(signal))
+ return Effect.callback<never, Error>((resume) => {
+ const onabort = () => resume(Effect.fail(aborted(signal)))
+ signal.addEventListener("abort", onabort, { once: true })
+ return Effect.sync(() => signal.removeEventListener("abort", onabort))
+ })
+}
+
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
@@ -205,371 +185,295 @@ function row(data: Row): Row {
}
}
-function opts(cwd: string) {
- return {
- env: env(),
- preopens: { ".": cwd },
- }
+function parse(line: string) {
+ return Effect.try({
+ try: () => Result.parse(JSON.parse(line)),
+ catch: (cause) => new Error("invalid ripgrep output", { cause }),
+ })
}
-function check(cwd: string) {
- return Effect.tryPromise({
- try: () => fs.stat(cwd).catch(() => undefined),
- catch: toError,
- }).pipe(
- Effect.flatMap((stat) =>
- stat?.isDirectory()
- ? Effect.void
- : Effect.fail(
- Object.assign(new Error(`No such file or directory: '${cwd}'`), {
- code: "ENOENT",
- errno: -2,
- path: cwd,
- }),
- ),
- ),
- )
+function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
+ Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function filesArgs(input: FilesInput) {
- const args = ["--files", "--glob=!.git/*"]
+ const args = ["--no-config", "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
+ if (input.hidden === false) args.push("--glob=!.*")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
- for (const glob of input.glob) {
- args.push(`--glob=${glob}`)
- }
+ for (const glob of input.glob) args.push(`--glob=${glob}`)
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
- const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"]
+ const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
- for (const glob of input.glob) {
- args.push(`--glob=${glob}`)
- }
+ for (const glob of input.glob) args.push(`--glob=${glob}`)
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
}
-function parse(stdout: string) {
- return stdout
- .trim()
- .split(/\r?\n/)
- .filter(Boolean)
- .map((line) => Result.parse(JSON.parse(line)))
- .flatMap((item) => (item.type === "match" ? [row(item.data)] : []))
+function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
+ return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
}
-declare const OPENCODE_RIPGREP_WORKER_PATH: string
+export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner | HttpClient.HttpClient> =
+ Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
+ const spawner = yield* ChildProcessSpawner
+
+ const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
+ const handle = yield* spawner.spawn(
+ ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
+ )
+ const [stdout, stderr, code] = yield* Effect.all(
+ [
+ Stream.mkString(Stream.decodeText(handle.stdout)),
+ Stream.mkString(Stream.decodeText(handle.stderr)),
+ handle.exitCode,
+ ],
+ { concurrency: "unbounded" },
+ )
+ return { stdout, stderr, code }
+ }, Effect.scoped)
+
+ const extract = Effect.fnUntraced(function* (archive: string, config: (typeof PLATFORM)[keyof typeof PLATFORM]) {
+ const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
+
+ if (config.extension === "zip") {
+ const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
+ const result = yield* run(shell, [
+ "-NoProfile",
+ "-Command",
+ "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force",
+ archive,
+ dir,
+ ])
+ if (result.code !== 0) {
+ return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
+ }
+ }
-function target(): Effect.Effect<string | URL, Error> {
- if (typeof OPENCODE_RIPGREP_WORKER_PATH !== "undefined") {
- return Effect.succeed(OPENCODE_RIPGREP_WORKER_PATH)
- }
- const js = new URL("./ripgrep.worker.js", import.meta.url)
- return Effect.tryPromise({
- try: () => Filesystem.exists(fileURLToPath(js)),
- catch: toError,
- }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url))))
-}
+ if (config.extension === "tar.gz") {
+ const result = yield* run("tar", ["-xzf", archive, "-C", dir])
+ if (result.code !== 0) {
+ return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
+ }
+ }
-function worker() {
- return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
-}
+ return path.join(dir, `ripgrep-${VERSION}-${config.platform}`, process.platform === "win32" ? "rg.exe" : "rg")
+ }, Effect.scoped)
-function drain(buf: string, chunk: unknown, push: (line: string) => void) {
- const lines = (buf + text(chunk)).split(/\r?\n/)
- buf = lines.pop() || ""
- for (const line of lines) {
- if (line) push(line)
- }
- return buf
-}
+ const filepath = yield* Effect.cached(
+ Effect.gen(function* () {
+ const system = yield* Effect.sync(() => which("rg"))
+ if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
-function fail(queue: Queue.Queue<string, Error | Cause.Done>, err: Error) {
- Queue.failCauseUnsafe(queue, Cause.fail(err))
-}
+ const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
+ if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
-function searchDirect(input: SearchInput) {
- return Effect.tryPromise({
- try: () =>
- ripgrep(searchArgs(input), {
- buffer: true,
- ...opts(input.cwd),
- }),
- catch: toError,
- }).pipe(
- Effect.flatMap((ret) => {
- const out = ret.stdout ?? ""
- if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) {
- return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1))
- }
- return Effect.sync(() => ({
- items: ret.code === 1 ? [] : parse(out),
- partial: ret.code === 2,
- }))
- }),
- )
-}
+ const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
+ const config = PLATFORM[platformKey]
+ if (!config) {
+ return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
+ }
-function searchWorker(input: SearchInput) {
- if (input.signal?.aborted) return Effect.fail(abort(input.signal))
-
- return Effect.acquireUseRelease(
- worker(),
- (w) =>
- Effect.callback<SearchResult, Error>((resume, signal) => {
- let open = true
- const done = (effect: Effect.Effect<SearchResult, Error>) => {
- if (!open) return
- open = false
- resume(effect)
- }
- const onabort = () => done(Effect.fail(abort(input.signal)))
+ const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
+ const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
+ const archive = path.join(Global.Path.bin, filename)
- w.onerror = (evt) => {
- done(Effect.fail(toError(evt.error ?? evt.message)))
- }
- w.onmessage = (evt: MessageEvent<WorkerResult | WorkerError>) => {
- const msg = evt.data
- if (msg.type === "error") {
- done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error)))
- return
+ log.info("downloading ripgrep", { url })
+ yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
+
+ const bytes = yield* HttpClientRequest.get(url).pipe(
+ http.execute,
+ Effect.flatMap((response) => response.arrayBuffer),
+ Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
+ )
+ if (bytes.byteLength === 0) {
+ return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
}
- if (msg.code === 1) {
- done(Effect.succeed({ items: [], partial: false }))
- return
+
+ yield* fs.writeWithDirs(archive, new Uint8Array(bytes)).pipe(Effect.orDie)
+ const extracted = yield* extract(archive, config)
+ const exists = yield* fs.exists(extracted).pipe(Effect.orDie)
+ if (!exists) {
+ return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
}
- if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) {
- done(Effect.fail(error(msg.stderr, msg.code)))
- return
+
+ yield* fs.copyFile(extracted, target).pipe(Effect.orDie)
+ if (process.platform !== "win32") {
+ yield* fs.chmod(target, 0o755).pipe(Effect.orDie)
}
- done(
- Effect.sync(() => ({
- items: parse(msg.stdout),
- partial: msg.code === 2,
- })),
- )
- }
+ yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
+ return target
+ }),
+ )
- input.signal?.addEventListener("abort", onabort, { once: true })
- signal.addEventListener("abort", onabort, { once: true })
- w.postMessage({
- kind: "search",
- cwd: input.cwd,
- args: searchArgs(input),
- } satisfies Run)
-
- return Effect.sync(() => {
- input.signal?.removeEventListener("abort", onabort)
- signal.removeEventListener("abort", onabort)
- w.onerror = null
- w.onmessage = null
+ const check = Effect.fnUntraced(function* (cwd: string) {
+ if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
+ return yield* Effect.fail(
+ Object.assign(new Error(`No such file or directory: '${cwd}'`), {
+ code: "ENOENT",
+ errno: -2,
+ path: cwd,
+ }),
+ )
+ })
+
+ const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
+ const binary = yield* filepath
+ return ChildProcess.make(binary, args, {
+ cwd,
+ env: env(),
+ extendEnv: true,
+ stdin: "ignore",
})
- }),
- (w) => Effect.sync(() => w.terminate()),
- )
-}
+ })
+
+ const files: Interface["files"] = (input) =>
+ Stream.callback<string, PlatformError | Error>((queue) =>
+ Effect.gen(function* () {
+ yield* Effect.forkScoped(
+ Effect.gen(function* () {
+ yield* check(input.cwd)
+ const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
+ const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
+ const stdout = yield* Stream.decodeText(handle.stdout).pipe(
+ Stream.splitLines,
+ Stream.filter((line) => line.length > 0),
+ Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
+ Effect.forkScoped,
+ )
+ const code = yield* raceAbort(handle.exitCode, input.signal)
+ yield* Fiber.join(stdout)
+ if (code === 0 || code === 1) {
+ Queue.endUnsafe(queue)
+ return
+ }
+ fail(queue, error(yield* Fiber.join(stderr), code))
+ }).pipe(
+ Effect.catch((err) =>
+ Effect.sync(() => {
+ fail(queue, err)
+ }),
+ ),
+ ),
+ )
+ }),
+ )
-function filesDirect(input: FilesInput) {
- return Stream.callback<string, Error>(
- Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
- let buf = ""
- let err = ""
-
- const out = {
- write(chunk: unknown) {
- buf = drain(buf, chunk, (line) => {
- Queue.offerUnsafe(queue, clean(line))
- })
- },
- }
-
- const stderr = {
- write(chunk: unknown) {
- err += text(chunk)
- },
- }
-
- yield* Effect.forkScoped(
- Effect.gen(function* () {
- yield* check(input.cwd)
- const ret = yield* Effect.tryPromise({
- try: () =>
- ripgrep(filesArgs(input), {
- stdout: out,
- stderr,
- ...opts(input.cwd),
- }),
- catch: toError,
- })
- if (buf) Queue.offerUnsafe(queue, clean(buf))
- if (ret.code === 0 || ret.code === 1) {
- Queue.endUnsafe(queue)
- return
- }
- fail(queue, error(err, ret.code ?? 1))
- }).pipe(
- Effect.catch((err) =>
- Effect.sync(() => {
- fail(queue, err)
- }),
- ),
- ),
- )
- }),
- )
-}
+ const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
+ yield* check(input.cwd)
+
+ const program = Effect.scoped(
+ Effect.gen(function* () {
+ const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
+
+ const [items, stderr, code] = yield* Effect.all(
+ [
+ Stream.decodeText(handle.stdout).pipe(
+ Stream.splitLines,
+ Stream.filter((line) => line.length > 0),
+ Stream.mapEffect(parse),
+ Stream.filter((item): item is Match => item.type === "match"),
+ Stream.map((item) => row(item.data)),
+ Stream.runCollect,
+ Effect.map((chunk) => [...chunk]),
+ ),
+ Stream.mkString(Stream.decodeText(handle.stderr)),
+ handle.exitCode,
+ ],
+ { concurrency: "unbounded" },
+ )
+
+ if (code !== 0 && code !== 1 && code !== 2) {
+ return yield* Effect.fail(error(stderr, code))
+ }
+
+ return {
+ items: code === 1 ? [] : items,
+ partial: code === 2,
+ }
+ }),
+ )
+
+ return yield* raceAbort(program, input.signal)
+ })
-function filesWorker(input: FilesInput) {
- return Stream.callback<string, Error>(
- Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
- if (input.signal?.aborted) {
- fail(queue, abort(input.signal))
- return
- }
-
- const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate()))
- let open = true
- const close = () => {
- if (!open) return false
- open = false
- return true
- }
- const onabort = () => {
- if (!close()) return
- fail(queue, abort(input.signal))
- }
-
- w.onerror = (evt) => {
- if (!close()) return
- fail(queue, toError(evt.error ?? evt.message))
- }
- w.onmessage = (evt: MessageEvent<WorkerLine | WorkerDone | WorkerError>) => {
- const msg = evt.data
- if (msg.type === "line") {
- if (open) Queue.offerUnsafe(queue, msg.line)
- return
+ const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
+ log.info("tree", input)
+ const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
+
+ interface Node {
+ name: string
+ children: Map<string, Node>
}
- if (!close()) return
- if (msg.type === "error") {
- fail(queue, Object.assign(new Error(msg.error.message), msg.error))
- return
+
+ function child(node: Node, name: string) {
+ const item = node.children.get(name)
+ if (item) return item
+ const next = { name, children: new Map() }
+ node.children.set(name, next)
+ return next
}
- if (msg.code === 0 || msg.code === 1) {
- Queue.endUnsafe(queue)
- return
+
+ function count(node: Node): number {
+ return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
- fail(queue, error(msg.stderr, msg.code))
- }
-
- yield* Effect.acquireRelease(
- Effect.sync(() => {
- input.signal?.addEventListener("abort", onabort, { once: true })
- w.postMessage({
- kind: "files",
- cwd: input.cwd,
- args: filesArgs(input),
- } satisfies Run)
- }),
- () =>
- Effect.sync(() => {
- input.signal?.removeEventListener("abort", onabort)
- w.onerror = null
- w.onmessage = null
- }),
- )
- }),
- )
-}
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const source = (input: FilesInput) => {
- const useWorker = !!input.signal && typeof Worker !== "undefined"
- if (!useWorker && input.signal) {
- log.warn("worker unavailable, ripgrep abort disabled")
- }
- return useWorker ? filesWorker(input) : filesDirect(input)
- }
-
- const files: Interface["files"] = (input) => source(input)
-
- const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
- log.info("tree", input)
- const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
-
- interface Node {
- name: string
- children: Map<string, Node>
- }
-
- function child(node: Node, name: string) {
- const item = node.children.get(name)
- if (item) return item
- const next = { name, children: new Map() }
- node.children.set(name, next)
- return next
- }
-
- function count(node: Node): number {
- return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
- }
-
- const root: Node = { name: "", children: new Map() }
- for (const file of list) {
- if (file.includes(".opencode")) continue
- const parts = file.split(path.sep)
- if (parts.length < 2) continue
- let node = root
- for (const part of parts.slice(0, -1)) {
- node = child(node, part)
+ const root: Node = { name: "", children: new Map() }
+ for (const file of list) {
+ if (file.includes(".opencode")) continue
+ const parts = file.split(path.sep)
+ if (parts.length < 2) continue
+ let node = root
+ for (const part of parts.slice(0, -1)) {
+ node = child(node, part)
+ }
+ }
+
+ const total = count(root)
+ const limit = input.limit ?? total
+ const lines: string[] = []
+ const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((node) => ({ node, path: node.name }))
+
+ let used = 0
+ for (let i = 0; i < queue.length && used < limit; i++) {
+ const item = queue[i]
+ lines.push(item.path)
+ used++
+ queue.push(
+ ...Array.from(item.node.children.values())
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((node) => ({ node, path: `${item.path}/${node.name}` })),
+ )
}
- }
-
- const total = count(root)
- const limit = input.limit ?? total
- const lines: string[] = []
- const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
- .sort((a, b) => a.name.localeCompare(b.name))
- .map((node) => ({ node, path: node.name }))
-
- let used = 0
- for (let i = 0; i < queue.length && used < limit; i++) {
- const item = queue[i]
- lines.push(item.path)
- used++
- queue.push(
- ...Array.from(item.node.children.values())
- .sort((a, b) => a.name.localeCompare(b.name))
- .map((node) => ({ node, path: `${item.path}/${node.name}` })),
- )
- }
- if (total > used) lines.push(`[${total - used} truncated]`)
- return lines.join("\n")
- })
+ if (total > used) lines.push(`[${total - used} truncated]`)
+ return lines.join("\n")
+ })
- const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
- const useWorker = !!input.signal && typeof Worker !== "undefined"
- if (!useWorker && input.signal) {
- log.warn("worker unavailable, ripgrep abort disabled")
- }
- return yield* useWorker ? searchWorker(input) : searchDirect(input)
- })
+ return Service.of({ files, tree, search })
+ }),
+ )
- return Service.of({ files, tree, search })
- }),
+export const defaultLayer = layer.pipe(
+ Layer.provide(FetchHttpClient.layer),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(CrossSpawnSpawner.defaultLayer),
)
-export const defaultLayer = layer
-
export * as Ripgrep from "./ripgrep"
diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts
deleted file mode 100644
index 62094c7ac..000000000
--- a/packages/opencode/src/file/ripgrep.worker.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { ripgrep } from "ripgrep"
-
-function env() {
- const env = Object.fromEntries(
- Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
- )
- delete env.RIPGREP_CONFIG_PATH
- return env
-}
-
-function opts(cwd: string) {
- return {
- env: env(),
- preopens: { ".": cwd },
- }
-}
-
-type Run = {
- kind: "files" | "search"
- cwd: string
- args: string[]
-}
-
-function text(input: unknown) {
- if (typeof input === "string") return input
- if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
- if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
- return String(input)
-}
-
-function error(input: unknown) {
- if (input instanceof Error) {
- return {
- message: input.message,
- name: input.name,
- stack: input.stack,
- }
- }
-
- return {
- message: String(input),
- }
-}
-
-function clean(file: string) {
- return file.replace(/^\.[\\/]/, "")
-}
-
-onmessage = async (evt: MessageEvent<Run>) => {
- const msg = evt.data
-
- try {
- if (msg.kind === "search") {
- const ret = await ripgrep(msg.args, {
- buffer: true,
- ...opts(msg.cwd),
- })
- postMessage({
- type: "result",
- code: ret.code ?? 0,
- stdout: ret.stdout ?? "",
- stderr: ret.stderr ?? "",
- })
- return
- }
-
- let buf = ""
- let err = ""
- const out = {
- write(chunk: unknown) {
- buf += text(chunk)
- const lines = buf.split(/\r?\n/)
- buf = lines.pop() || ""
- for (const line of lines) {
- if (line) postMessage({ type: "line", line: clean(line) })
- }
- },
- }
- const stderr = {
- write(chunk: unknown) {
- err += text(chunk)
- },
- }
-
- const ret = await ripgrep(msg.args, {
- stdout: out,
- stderr,
- ...opts(msg.cwd),
- })
-
- if (buf) postMessage({ type: "line", line: clean(buf) })
- postMessage({
- type: "done",
- code: ret.code ?? 0,
- stderr: err,
- })
- } catch (err) {
- postMessage({
- type: "error",
- error: error(err),
- })
- }
-}
diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts
deleted file mode 100644
index cc26682d5..000000000
--- a/packages/opencode/src/file/time.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
-import { InstanceState } from "@/effect"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Flag } from "@/flag/flag"
-import type { SessionID } from "@/session/schema"
-import { Log } from "../util"
-
-const log = Log.create({ service: "file.time" })
-
-export type Stamp = {
- readonly read: Date
- readonly mtime: number | undefined
- readonly size: number | undefined
-}
-
-const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
- const value = reads.get(sessionID)
- if (value) return value
-
- const next = new Map<string, Stamp>()
- reads.set(sessionID, next)
- return next
-}
-
-interface State {
- reads: Map<SessionID, Map<string, Stamp>>
- locks: Map<string, Semaphore.Semaphore>
-}
-
-export interface Interface {
- readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
- readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
- readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
- readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
-
-export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fsys = yield* AppFileSystem.Service
- const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
-
- const stamp = Effect.fnUntraced(function* (file: string) {
- const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
- return {
- read: yield* DateTime.nowAsDate,
- mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
- size: info ? Number(info.size) : undefined,
- }
- })
- const state = yield* InstanceState.make<State>(
- Effect.fn("FileTime.state")(() =>
- Effect.succeed({
- reads: new Map<SessionID, Map<string, Stamp>>(),
- locks: new Map<string, Semaphore.Semaphore>(),
- }),
- ),
- )
-
- const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
- filepath = AppFileSystem.normalizePath(filepath)
- const locks = (yield* InstanceState.get(state)).locks
- const lock = locks.get(filepath)
- if (lock) return lock
-
- const next = Semaphore.makeUnsafe(1)
- locks.set(filepath, next)
- return next
- })
-
- const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
- file = AppFileSystem.normalizePath(file)
- const reads = (yield* InstanceState.get(state)).reads
- log.info("read", { sessionID, file })
- session(reads, sessionID).set(file, yield* stamp(file))
- })
-
- const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
- file = AppFileSystem.normalizePath(file)
- const reads = (yield* InstanceState.get(state)).reads
- return reads.get(sessionID)?.get(file)?.read
- })
-
- const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
- if (disableCheck) return
- filepath = AppFileSystem.normalizePath(filepath)
-
- const reads = (yield* InstanceState.get(state)).reads
- const time = reads.get(sessionID)?.get(filepath)
- if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
-
- const next = yield* stamp(filepath)
- const changed = next.mtime !== time.mtime || next.size !== time.size
- if (!changed) return
-
- throw new Error(
- `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
- )
- })
-
- const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
- return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
- })
-
- return Service.of({ read, get, assert, withLock })
- }),
-).pipe(Layer.orDie)
-
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
-
-export * as FileTime from "./time"
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 416f641c4..72c8931f5 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -70,7 +70,6 @@ export const Flag = {
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
- OPENCODE_DISABLE_FILETIME_CHECK: Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(Config.withDefault(false)),
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
@@ -82,7 +81,7 @@ export const Flag = {
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
- OPENCODE_EXPERIMENTAL_HTTPAPI: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
+ OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
// Evaluated at access time (not module load) because tests, the CLI, and
diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts
index 36249db7d..03f836527 100644
--- a/packages/opencode/src/format/formatter.ts
+++ b/packages/opencode/src/format/formatter.ts
@@ -1,15 +1,17 @@
import { Npm } from "../npm"
-import { Instance } from "../project/instance"
+import type { InstanceContext } from "../project/instance"
import { Filesystem } from "../util"
import { Process } from "../util"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
+export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {}
+
export interface Info {
name: string
environment?: Record<string, string>
extensions: string[]
- enabled(): Promise<string[] | false>
+ enabled(context: Context): Promise<string[] | false>
}
export const gofmt: Info = {
@@ -65,8 +67,8 @@ export const prettier: Info = {
".graphql",
".gql",
],
- async enabled() {
- const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
+ async enabled(context) {
+ const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -87,9 +89,9 @@ export const oxfmt: Info = {
BUN_BE_BUN: "1",
},
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
- async enabled() {
+ async enabled(context) {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
- const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
+ const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -137,10 +139,10 @@ export const biome: Info = {
".graphql",
".gql",
],
- async enabled() {
+ async enabled(context) {
const configs = ["biome.json", "biome.jsonc"]
for (const config of configs) {
- const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
+ const found = await Filesystem.findUp(config, context.directory, context.worktree)
if (found.length > 0) {
const bin = await Npm.which("@biomejs/biome")
if (bin) return [bin, "format", "--write", "$FILE"]
@@ -163,8 +165,8 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
- async enabled() {
- const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
+ async enabled(context) {
+ const items = await Filesystem.findUp(".clang-format", context.directory, context.worktree)
if (items.length > 0) {
const match = which("clang-format")
if (match) return [match, "-i", "$FILE"]
@@ -186,11 +188,11 @@ export const ktlint: Info = {
export const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
- async enabled() {
+ async enabled(context) {
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
- const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
+ const found = await Filesystem.findUp(config, context.directory, context.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
@@ -202,7 +204,7 @@ export const ruff: Info = {
}
const deps = ["requirements.txt", "pyproject.toml", "Pipfile"]
for (const dep of deps) {
- const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
+ const found = await Filesystem.findUp(dep, context.directory, context.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return ["ruff", "format", "$FILE"]
@@ -233,8 +235,8 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
extensions: [".py", ".pyi"],
- async enabled() {
- if (await ruff.enabled()) return false
+ async enabled(context) {
+ if (await ruff.enabled(context)) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
@@ -286,9 +288,9 @@ export const dart: Info = {
export const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
- async enabled() {
+ async enabled(context) {
if (!which("ocamlformat")) return false
- const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
+ const items = await Filesystem.findUp(".ocamlformat", context.directory, context.worktree)
if (items.length > 0) return ["ocamlformat", "-i", "$FILE"]
return false
},
@@ -357,8 +359,8 @@ export const rustfmt: Info = {
export const pint: Info = {
name: "pint",
extensions: [".php"],
- async enabled() {
- const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
+ async enabled(context) {
+ const items = await Filesystem.findUp("composer.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
require?: Record<string, string>
diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index 2d0f80a10..85934ce9c 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -37,14 +37,14 @@ export const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make(
- Effect.fn("Format.state")(function* (_ctx) {
+ Effect.fn("Format.state")(function* (ctx) {
const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {}
async function getCommand(item: Formatter.Info) {
let cmd = commands[item.name]
if (cmd === false || cmd === undefined) {
- cmd = await item.enabled()
+ cmd = await item.enabled(ctx)
commands[item.name] = cmd
}
return cmd
@@ -153,7 +153,7 @@ export const layer = Layer.effect(
...info,
name,
extensions: info.extensions ?? [],
- enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false,
+ enabled: builtIn && !info.command ? builtIn.enabled : async (_context) => info.command ?? false,
}
}
}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 67de87c2a..0a3a927b4 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -38,6 +38,9 @@ import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
+import { ensureProcessMetadata } from "./util/opencode-process"
+
+const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -108,6 +111,8 @@ const cli = yargs(args)
Log.Default.info("opencode", {
version: InstallationVersion,
args: process.argv.slice(2),
+ process_role: processMetadata.processRole,
+ run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 59a64ca1e..b20e8ae7f 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -11,7 +11,6 @@ import z from "zod"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout"
-import { Instance } from "../project/instance"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
@@ -39,7 +38,7 @@ export const Event = {
),
}
-export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
+export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
@@ -145,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return connection
},
notify: {
- async open(input: { path: string }) {
- input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
- const text = await Filesystem.readText(input.path)
- const extension = path.extname(input.path)
+ async open(request: { path: string }) {
+ request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
+ const text = await Filesystem.readText(request.path)
+ const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
- const version = files[input.path]
+ const version = files[request.path]
if (version !== undefined) {
- log.info("workspace/didChangeWatchedFiles", input)
+ log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
- uri: pathToFileURL(input.path).href,
+ uri: pathToFileURL(request.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
- files[input.path] = next
+ files[request.path] = next
log.info("textDocument/didChange", {
- path: input.path,
+ path: request.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
- uri: pathToFileURL(input.path).href,
+ uri: pathToFileURL(request.path).href,
version: next,
},
contentChanges: [{ text }],
@@ -179,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return
}
- log.info("workspace/didChangeWatchedFiles", input)
+ log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
- uri: pathToFileURL(input.path).href,
+ uri: pathToFileURL(request.path).href,
type: 1, // Created
},
],
})
- log.info("textDocument/didOpen", input)
- diagnostics.delete(input.path)
+ log.info("textDocument/didOpen", request)
+ diagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
- uri: pathToFileURL(input.path).href,
+ uri: pathToFileURL(request.path).href,
languageId,
version: 0,
text,
},
})
- files[input.path] = 0
+ files[request.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
- async waitForDiagnostics(input: { path: string }) {
+ async waitForDiagnostics(request: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
- path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
+ path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts
index 43c830987..aa519f9f7 100644
--- a/packages/opencode/src/lsp/lsp.ts
+++ b/packages/opencode/src/lsp/lsp.ts
@@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "../config"
-import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
const log = Log.create({ service: "lsp" })
@@ -162,7 +162,7 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const state = yield* InstanceState.make<State>(
- Effect.fn("LSP.state")(function* () {
+ Effect.fn("LSP.state")(function* (ctx) {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
@@ -187,7 +187,7 @@ export const layer = Layer.effect(
servers[name] = {
...existing,
id: name,
- root: existing?.root ?? (async () => Instance.directory),
+ root: existing?.root ?? (async (_file, ctx) => ctx.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
@@ -225,7 +225,13 @@ export const layer = Layer.effect(
)
const getClients = Effect.fnUntraced(function* (file: string) {
- if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
+ const ctx = yield* InstanceState.context
+ if (
+ !AppFileSystem.contains(ctx.directory, file) &&
+ (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
+ ) {
+ return [] as LSPClient.Info[]
+ }
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
@@ -233,7 +239,7 @@ export const layer = Layer.effect(
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
- .spawn(root)
+ .spawn(root, ctx)
.then((value) => {
if (!value) s.broken.add(key)
return value
@@ -251,6 +257,7 @@ export const layer = Layer.effect(
serverID: server.id,
server: handle,
root,
+ directory: ctx.directory,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
@@ -273,7 +280,7 @@ export const layer = Layer.effect(
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
- const root = await server.root(file)
+ const root = await server.root(file, ctx)
if (!root) continue
if (s.broken.has(root + server.id)) continue
@@ -326,13 +333,14 @@ export const layer = Layer.effect(
})
const status = Effect.fn("LSP.status")(function* () {
+ const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
- root: path.relative(Instance.directory, client.root),
+ root: path.relative(ctx.directory, client.root),
status: "connected",
})
}
@@ -340,12 +348,13 @@ export const layer = Layer.effect(
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
+ const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
- const root = await server.root(file)
+ const root = await server.root(file, ctx)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 760e8eaba..918236806 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -6,7 +6,7 @@ import { Log } from "../util"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util"
-import { Instance } from "../project/instance"
+import type { InstanceContext } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util"
import { Process } from "../util"
@@ -29,15 +29,15 @@ export interface Handle {
initialization?: Record<string, any>
}
-type RootFunction = (file: string) => Promise<string | undefined>
+type RootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
- return async (file) => {
+ return async (file, ctx) => {
if (excludePatterns) {
const excludedFiles = Filesystem.up({
targets: excludePatterns,
start: path.dirname(file),
- stop: Instance.directory,
+ stop: ctx.directory,
})
const excluded = await excludedFiles.next()
await excludedFiles.return()
@@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
const files = Filesystem.up({
targets: includePatterns,
start: path.dirname(file),
- stop: Instance.directory,
+ stop: ctx.directory,
})
const first = await files.next()
await files.return()
- if (!first.value) return Instance.directory
+ if (!first.value) return ctx.directory
return path.dirname(first.value)
}
}
@@ -60,16 +60,16 @@ export interface Info {
extensions: string[]
global?: boolean
root: RootFunction
- spawn(root: string): Promise<Handle | undefined>
+ spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
}
export const Deno: Info = {
id: "deno",
- root: async (file) => {
+ root: async (file, ctx) => {
const files = Filesystem.up({
targets: ["deno.json", "deno.jsonc"],
start: path.dirname(file),
- stop: Instance.directory,
+ stop: ctx.directory,
})
const first = await files.next()
await files.return()
@@ -98,8 +98,8 @@ export const Typescript: Info = {
["deno.json", "deno.jsonc"],
),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
- async spawn(root) {
- const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
+ async spawn(root, ctx) {
+ const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const bin = await Npm.which("typescript-language-server")
@@ -154,8 +154,8 @@ export const ESLint: Info = {
id: "eslint",
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
- async spawn(root) {
- const eslint = Module.resolve("eslint", Instance.directory)
+ async spawn(root, ctx) {
+ const eslint = Module.resolve("eslint", ctx.directory)
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -219,7 +219,7 @@ export const Oxlint: Info = {
"package.json",
]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
- async spawn(root) {
+ async spawn(root, ctx) {
const ext = process.platform === "win32" ? ".cmd" : ""
const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext)
@@ -232,7 +232,7 @@ export const Oxlint: Info = {
const candidates = Filesystem.up({
targets: [target],
start: root,
- stop: Instance.worktree,
+ stop: ctx.worktree,
})
const first = await candidates.next()
await candidates.return()
@@ -344,10 +344,10 @@ export const Biome: Info = {
export const Gopls: Info = {
id: "gopls",
- root: async (file) => {
- const work = await NearestRoot(["go.work"])(file)
+ root: async (file, ctx) => {
+ const work = await NearestRoot(["go.work"])(file, ctx)
if (work) return work
- return NearestRoot(["go.mod", "go.sum"])(file)
+ return NearestRoot(["go.mod", "go.sum"])(file, ctx)
},
extensions: [".go"],
async spawn(root) {
@@ -810,8 +810,8 @@ export const SourceKit: Info = {
export const RustAnalyzer: Info = {
id: "rust",
- root: async (root) => {
- const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
+ root: async (file, ctx) => {
+ const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
if (crateRoot === undefined) {
return undefined
}
@@ -834,7 +834,7 @@ export const RustAnalyzer: Info = {
currentDir = parentDir
// Stop if we've gone above the app root
- if (!currentDir.startsWith(Instance.worktree)) break
+ if (!currentDir.startsWith(ctx.worktree)) break
}
return crateRoot
@@ -1031,8 +1031,8 @@ export const Astro: Info = {
id: "astro",
extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
- async spawn(root) {
- const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
+ async spawn(root, ctx) {
+ const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
if (!tsserver) {
log.info("typescript not found, required for Astro language server")
return
@@ -1067,7 +1067,7 @@ export const Astro: Info = {
export const JDTLS: Info = {
id: "jdtls",
- root: async (file) => {
+ root: async (file, ctx) => {
// Without exclusions, NearestRoot defaults to instance directory so we can't
// distinguish between a) no project found and b) project found at instance dir.
// So we can't choose the root from (potential) monorepo markers first.
@@ -1077,12 +1077,12 @@ export const JDTLS: Info = {
const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
- NearestRoot(
- ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
- exclusionsForMonorepos,
- )(file),
- NearestRoot(gradleMarkers, settingsMarkers)(file),
- NearestRoot(settingsMarkers)(file),
+ NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos)(
+ file,
+ ctx,
+ ),
+ NearestRoot(gradleMarkers, settingsMarkers)(file, ctx),
+ NearestRoot(settingsMarkers)(file, ctx),
])
// If projectRoot is undefined we know we are in a monorepo or no project at all.
@@ -1189,18 +1189,18 @@ export const JDTLS: Info = {
export const KotlinLS: Info = {
id: "kotlin-ls",
extensions: [".kt", ".kts"],
- root: async (file) => {
+ root: async (file, ctx) => {
// 1) Nearest Gradle root (multi-project or included build)
- const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file)
+ const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx)
if (settingsRoot) return settingsRoot
// 2) Gradle wrapper (strong root signal)
- const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file)
+ const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx)
if (wrapperRoot) return wrapperRoot
// 3) Single-project or module-level build
- const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file)
+ const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx)
if (buildRoot) return buildRoot
// 4) Maven fallback
- return NearestRoot(["pom.xml"])(file)
+ return NearestRoot(["pom.xml"])(file, ctx)
},
async spawn(root) {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
@@ -1539,7 +1539,7 @@ export const Ocaml: Info = {
export const BashLS: Info = {
id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"],
- root: async () => Instance.directory,
+ root: async (_file, ctx) => ctx.directory,
async spawn(root) {
let binary = which("bash-language-server")
const args: string[] = []
@@ -1734,7 +1734,7 @@ export const TexLab: Info = {
export const DockerfileLS: Info = {
id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"],
- root: async () => Instance.directory,
+ root: async (_file, ctx) => ctx.directory,
async spawn(root) {
let binary = which("docker-langserver")
const args: string[] = []
@@ -1799,16 +1799,16 @@ export const Clojure: Info = {
export const Nixd: Info = {
id: "nixd",
extensions: [".nix"],
- root: async (file) => {
+ root: async (file, ctx) => {
// First, look for flake.nix - the most reliable Nix project root indicator
- const flakeRoot = await NearestRoot(["flake.nix"])(file)
- if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot
+ const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx)
+ if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot
// If no flake.nix, fall back to git repository root
- if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree
+ if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree
// Finally, use the instance directory as fallback
- return Instance.directory
+ return ctx.directory
},
async spawn(root) {
const nixd = which("nixd")
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index ba53e7c0b..09fcfc756 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -14,7 +14,6 @@ import { ConfigMCP } from "../config/mcp"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4"
-import { Instance } from "../project/instance"
import { Installation } from "../installation"
import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
@@ -391,7 +390,7 @@ export const layer = Layer.effect(
mcp: ConfigMCP.Info & { type: "local" },
) {
const [cmd, ...args] = mcp.command
- const cwd = Instance.directory
+ const cwd = yield* InstanceState.directory
const transport = new StdioClientTransport({
stderr: "pipe",
command: cmd,
diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts
index 425b27f42..477e99e06 100644
--- a/packages/opencode/src/npm/index.ts
+++ b/packages/opencode/src/npm/index.ts
@@ -1,198 +1,293 @@
-import semver from "semver"
-import z from "zod"
-import { NamedError } from "@opencode-ai/shared/util/error"
-import { Global } from "../global"
-import { Log } from "../util"
+export * as Npm from "."
+
import path from "path"
-import { readdir, rm } from "fs/promises"
-import { Filesystem } from "@/util"
-import { Flock } from "@opencode-ai/shared/util/flock"
+import npa from "npm-package-arg"
+import semver from "semver"
+import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
+import { NodeFileSystem } from "@effect/platform-node"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Global } from "@opencode-ai/shared/global"
+import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
-const log = Log.create({ service: "npm" })
-const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
+import { makeRuntime } from "../effect/runtime"
-export const InstallFailedError = NamedError.create(
- "NpmInstallFailedError",
- z.object({
- pkg: z.string(),
- }),
-)
+export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
+ add: Schema.Array(Schema.String).pipe(Schema.optional),
+ dir: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+export interface EntryPoint {
+ readonly directory: string
+ readonly entrypoint: Option.Option<string>
+}
+
+export interface Interface {
+ readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
+ readonly install: (
+ dir: string,
+ input?: {
+ add: {
+ name: string
+ version?: string
+ }[]
+ },
+ ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
+ readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
+ readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
+
+const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
-function directory(pkg: string) {
- return path.join(Global.Path.cache, "packages", sanitize(pkg))
-}
-
-function resolveEntryPoint(name: string, dir: string) {
- let entrypoint: string | undefined
+const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
+ let entrypoint: Option.Option<string>
try {
- entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
- } catch {}
- const result = {
+ const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
+ entrypoint = Option.some(resolved)
+ } catch {
+ entrypoint = Option.none()
+ }
+ return {
directory: dir,
entrypoint,
}
- return result
}
-export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
- const response = await fetch(`https://registry.npmjs.org/${pkg}`)
- if (!response.ok) {
- log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
- return false
- }
+interface ArboristNode {
+ name: string
+ path: string
+}
- const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
- const latestVersion = data?.["dist-tags"]?.latest
- if (!latestVersion) {
- log.warn("No latest version found, using cached", { pkg, cachedVersion })
- return false
- }
+interface ArboristTree {
+ edgesOut: Map<string, { to?: ArboristNode }>
+}
- const range = /[\s^~*xX<>|=]/.test(cachedVersion)
- if (range) return !semver.satisfies(latestVersion, cachedVersion)
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const afs = yield* AppFileSystem.Service
+ const global = yield* Global.Service
+ const fs = yield* FileSystem.FileSystem
+ const flock = yield* EffectFlock.Service
+ const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
+ const reify = (input: { dir: string; add?: string[] }) =>
+ Effect.gen(function* () {
+ yield* flock.acquire(`npm-install:${input.dir}`)
+ const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
+ const arborist = new Arborist({
+ path: input.dir,
+ binLinks: true,
+ progress: false,
+ savePrefix: "",
+ ignoreScripts: true,
+ })
+ return yield* Effect.tryPromise({
+ try: () =>
+ arborist.reify({
+ add: input?.add || [],
+ save: true,
+ saveType: "prod",
+ }),
+ catch: (cause) =>
+ new InstallFailedError({
+ cause,
+ add: input?.add,
+ dir: input.dir,
+ }),
+ }) as Effect.Effect<ArboristTree, InstallFailedError>
+ }).pipe(
+ Effect.withSpan("Npm.reify", {
+ attributes: input,
+ }),
+ )
- return semver.lt(cachedVersion, latestVersion)
-}
+ const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
+ const response = yield* Effect.tryPromise({
+ try: () => fetch(`https://registry.npmjs.org/${pkg}`),
+ catch: () => undefined,
+ }).pipe(Effect.orElseSucceed(() => undefined))
-export async function add(pkg: string) {
- const { Arborist } = await import("@npmcli/arborist")
- const dir = directory(pkg)
- await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
- log.info("installing package", {
- pkg,
- })
-
- const arborist = new Arborist({
- path: dir,
- binLinks: true,
- progress: false,
- savePrefix: "",
- ignoreScripts: true,
- })
- const tree = await arborist.loadVirtual().catch(() => {})
- if (tree) {
- const first = tree.edgesOut.values().next().value?.to
- if (first) {
- return resolveEntryPoint(first.name, first.path)
- }
- }
+ if (!response || !response.ok) {
+ return false
+ }
- const result = await arborist
- .reify({
- add: [pkg],
- save: true,
- saveType: "prod",
+ const data = yield* Effect.tryPromise({
+ try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
+ catch: () => undefined,
+ }).pipe(Effect.orElseSucceed(() => undefined))
+
+ const latestVersion = data?.["dist-tags"]?.latest
+ if (!latestVersion) {
+ return false
+ }
+
+ const range = /[\s^~*xX<>|=]/.test(cachedVersion)
+ if (range) return !semver.satisfies(latestVersion, cachedVersion)
+
+ return semver.lt(cachedVersion, latestVersion)
})
- .catch((cause) => {
- throw new InstallFailedError(
- { pkg },
- {
- cause,
- },
+
+ const add = Effect.fn("Npm.add")(function* (pkg: string) {
+ const dir = directory(pkg)
+ const name = (() => {
+ try {
+ return npa(pkg).name ?? pkg
+ } catch {
+ return pkg
+ }
+ })()
+
+ if (yield* afs.existsSafe(dir)) {
+ return resolveEntryPoint(name, path.join(dir, "node_modules", name))
+ }
+
+ const tree = yield* reify({ dir, add: [pkg] })
+ const first = tree.edgesOut.values().next().value?.to
+ if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
+ return resolveEntryPoint(first.name, first.path)
+ }, Effect.scoped)
+
+ const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
+ const canWrite = yield* afs.access(dir, { writable: true }).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
)
- })
+ if (!canWrite) return
- const first = result.edgesOut.values().next().value?.to
- if (!first) throw new InstallFailedError({ pkg })
- return resolveEntryPoint(first.name, first.path)
-}
+ const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
+ if (
+ yield* Effect.gen(function* () {
+ const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
+ if (!nodeModulesExists) {
+ yield* reify({ add, dir })
+ return true
+ }
+ return false
+ }).pipe(Effect.withSpan("Npm.checkNodeModules"))
+ )
+ return
-export async function install(dir: string) {
- await using _ = await Flock.acquire(`npm-install:${dir}`)
- log.info("checking dependencies", { dir })
-
- const reify = async () => {
- const { Arborist } = await import("@npmcli/arborist")
- const arb = new Arborist({
- path: dir,
- binLinks: true,
- progress: false,
- savePrefix: "",
- ignoreScripts: true,
- })
- await arb.reify().catch(() => {})
- }
+ yield* Effect.gen(function* () {
+ const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
+ const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
- if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
- log.info("node_modules missing, reifying")
- await reify()
- return
- }
+ const pkgAny = pkg as any
+ const lockAny = lock as any
+ const declared = new Set([
+ ...Object.keys(pkgAny?.dependencies || {}),
+ ...Object.keys(pkgAny?.devDependencies || {}),
+ ...Object.keys(pkgAny?.peerDependencies || {}),
+ ...Object.keys(pkgAny?.optionalDependencies || {}),
+ ...(input?.add || []).map((pkg) => pkg.name),
+ ])
+
+ const root = lockAny?.packages?.[""] || {}
+ const locked = new Set([
+ ...Object.keys(root?.dependencies || {}),
+ ...Object.keys(root?.devDependencies || {}),
+ ...Object.keys(root?.peerDependencies || {}),
+ ...Object.keys(root?.optionalDependencies || {}),
+ ])
+
+ for (const name of declared) {
+ if (!locked.has(name)) {
+ yield* reify({ dir, add })
+ return
+ }
+ }
+ }).pipe(Effect.withSpan("Npm.checkDirty"))
- type PackageDeps = Record<string, string>
- type PackageJson = {
- dependencies?: PackageDeps
- devDependencies?: PackageDeps
- peerDependencies?: PackageDeps
- optionalDependencies?: PackageDeps
- }
- const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
- const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
- packages?: Record<string, PackageJson>
- }>(path.join(dir, "package-lock.json")).catch(() => ({}))
-
- const declared = new Set([
- ...Object.keys(pkg.dependencies || {}),
- ...Object.keys(pkg.devDependencies || {}),
- ...Object.keys(pkg.peerDependencies || {}),
- ...Object.keys(pkg.optionalDependencies || {}),
- ])
-
- const root = lock.packages?.[""] || {}
- const locked = new Set([
- ...Object.keys(root.dependencies || {}),
- ...Object.keys(root.devDependencies || {}),
- ...Object.keys(root.peerDependencies || {}),
- ...Object.keys(root.optionalDependencies || {}),
- ])
-
- for (const name of declared) {
- if (!locked.has(name)) {
- log.info("dependency not in lock file, reifying", { name })
- await reify()
return
- }
- }
+ }, Effect.scoped)
+
+ const which = Effect.fn("Npm.which")(function* (pkg: string) {
+ const dir = directory(pkg)
+ const binDir = path.join(dir, "node_modules", ".bin")
+
+ const pick = Effect.fnUntraced(function* () {
+ const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+
+ if (files.length === 0) return Option.none<string>()
+ if (files.length === 1) return Option.some(files[0])
+
+ const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
+
+ if (Option.isSome(pkgJson)) {
+ const parsed = pkgJson.value as { bin?: string | Record<string, string> }
+ if (parsed?.bin) {
+ const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
+ const bin = parsed.bin
+ if (typeof bin === "string") return Option.some(unscoped)
+ const keys = Object.keys(bin)
+ if (keys.length === 1) return Option.some(keys[0])
+ return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
+ }
+ }
+
+ return Option.some(files[0])
+ })
+
+ return yield* Effect.gen(function* () {
+ const bin = yield* pick()
+ if (Option.isSome(bin)) {
+ return Option.some(path.join(binDir, bin.value))
+ }
- log.info("dependencies in sync")
+ yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
+
+ yield* add(pkg)
+
+ const resolved = yield* pick()
+ if (Option.isNone(resolved)) return Option.none<string>()
+ return Option.some(path.join(binDir, resolved.value))
+ }).pipe(
+ Effect.scoped,
+ Effect.orElseSucceed(() => Option.none<string>()),
+ )
+ })
+
+ return Service.of({
+ add,
+ install,
+ outdated,
+ which,
+ })
+ }),
+)
+
+export const defaultLayer = layer.pipe(
+ Layer.provide(EffectFlock.layer),
+ Layer.provide(AppFileSystem.layer),
+ Layer.provide(Global.layer),
+ Layer.provide(NodeFileSystem.layer),
+)
+
+const { runPromise } = makeRuntime(Service, defaultLayer)
+
+export async function install(...args: Parameters<Interface["install"]>) {
+ return runPromise((svc) => svc.install(...args))
}
-export async function which(pkg: string) {
- const dir = directory(pkg)
- const binDir = path.join(dir, "node_modules", ".bin")
-
- const pick = async () => {
- const files = await readdir(binDir).catch(() => [])
- if (files.length === 0) return undefined
- if (files.length === 1) return files[0]
- // Multiple binaries — resolve from package.json bin field like npx does
- const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
- path.join(dir, "node_modules", pkg, "package.json"),
- ).catch(() => undefined)
- if (pkgJson?.bin) {
- const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
- const bin = pkgJson.bin
- if (typeof bin === "string") return unscoped
- const keys = Object.keys(bin)
- if (keys.length === 1) return keys[0]
- return bin[unscoped] ? unscoped : keys[0]
- }
- return files[0]
+export async function add(...args: Parameters<Interface["add"]>) {
+ const entry = await runPromise((svc) => svc.add(...args))
+ return {
+ directory: entry.directory,
+ entrypoint: Option.getOrUndefined(entry.entrypoint),
}
+}
- const bin = await pick()
- if (bin) return path.join(binDir, bin)
-
- await rm(path.join(dir, "package-lock.json"), { force: true })
- await add(pkg)
- const resolved = await pick()
- if (!resolved) return
- return path.join(binDir, resolved)
+export async function outdated(...args: Parameters<Interface["outdated"]>) {
+ return runPromise((svc) => svc.outdated(...args))
}
-export * as Npm from "."
+export async function which(...args: Parameters<Interface["which"]>) {
+ const resolved = await runPromise((svc) => svc.which(...args))
+ return Option.getOrUndefined(resolved)
+}
diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts
index c9b7e3c1c..9b6f54459 100644
--- a/packages/opencode/src/plugin/github-copilot/copilot.ts
+++ b/packages/opencode/src/plugin/github-copilot/copilot.ts
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
-import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
@@ -335,6 +334,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
if (incoming.model.api.id.includes("gpt")) {
output.maxOutputTokens = undefined
}
+
+ // GitHub Copilot's /v1/messages shim rejects the GA `eager_input_streaming`
+ // field on tool definitions ("Extra inputs are not permitted"). Opt out of
+ // the @ai-sdk/anthropic default so it stops injecting the field.
+ if (incoming.model.api.npm === "@ai-sdk/anthropic") {
+ output.options.toolStreaming = false
+ }
},
"chat.headers": async (incoming, output) => {
if (!incoming.model.providerID.includes("github-copilot")) return
diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts
index 71d21afbe..0aac0d3f5 100644
--- a/packages/opencode/src/plugin/github-copilot/models.ts
+++ b/packages/opencode/src/plugin/github-copilot/models.ts
@@ -10,6 +10,11 @@ export const schema = z.object({
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
+ policy: z
+ .object({
+ state: z.string().optional(),
+ })
+ .optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
@@ -122,7 +127,9 @@ export async function get(
})
const result = { ...existing }
- const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
+ const remote = new Map(
+ data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
+ )
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts
index 0245d311e..e61612561 100644
--- a/packages/opencode/src/plugin/loader.ts
+++ b/packages/opencode/src/plugin/loader.ts
@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader {
+ // A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = {
spec: string
options: ConfigPlugin.Options | undefined
deprecated: boolean
}
+
+ // A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
+
+ // A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
+
+ // A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
+ // Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void
+ // Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
+ // Called for operational failures such as install, compatibility, or dynamic import errors.
error?: (
candidate: Candidate,
retry: boolean,
@@ -46,11 +56,16 @@ export namespace PluginLoader {
) => void
}
+ // Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
+ // Resolve a configured plugin into a concrete entrypoint that can later be imported.
+ //
+ // The stages here intentionally separate install/target resolution, entrypoint detection,
+ // and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve(
plan: Plan,
kind: PluginKind,
@@ -59,6 +74,7 @@ export namespace PluginLoader {
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
+ // First make sure the plugin exists locally, installing npm plugins on demand.
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
@@ -67,6 +83,7 @@ export namespace PluginLoader {
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
+ // Then inspect the target for the requested server/tui entrypoint.
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
@@ -86,6 +103,8 @@ export namespace PluginLoader {
},
}
+ // npm plugins can declare which opencode versions they support; file plugins are treated
+ // as local development code and skip this compatibility gate.
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@@ -96,6 +115,7 @@ export namespace PluginLoader {
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
+ // Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
@@ -107,6 +127,8 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } }
}
+ // Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
+ // import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
@@ -116,11 +138,17 @@ export namespace PluginLoader {
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
+
+ // Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return
+
report?.start?.(candidate, retry)
+
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
+ // Missing entrypoints are handled separately so callers can still inspect package metadata,
+ // for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
@@ -131,11 +159,15 @@ export namespace PluginLoader {
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
+
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
+
+ // The default behavior is to return the successfully loaded plugin as-is, but callers can
+ // provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
}
@@ -149,6 +181,11 @@ export namespace PluginLoader {
report?: Report
}
+ // Resolve and load all configured plugins in parallel.
+ //
+ // If `wait` is provided, file-based plugins that initially failed are retried once after the
+ // caller finishes preparing dependencies. This supports local plugins that depend on an install
+ // step happening elsewhere before their entrypoint becomes loadable.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
@@ -160,6 +197,9 @@ export namespace PluginLoader {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
+
+ // Only local file plugins are retried. npm plugins already attempted installation during
+ // the first pass, while file plugins may need the caller's dependency preparation to finish.
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
@@ -167,6 +207,8 @@ export namespace PluginLoader {
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
+
+ // Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts
index 11f36c41a..ca821216d 100644
--- a/packages/opencode/src/plugin/shared.ts
+++ b/packages/opencode/src/plugin/shared.ts
@@ -2,9 +2,9 @@ import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver"
-import { Npm } from "../npm"
import { Filesystem } from "@/util"
import { isRecord } from "@/util/record"
+import { Npm } from "@/npm"
// Old npm package names for plugins that are now built-in
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 056eede01..1c5109620 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -96,7 +96,7 @@ export const Instance = {
if (AppFileSystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
- if (Instance.worktree === "/") return false
+ if (instance.worktree === "/") return false
return AppFileSystem.contains(instance.worktree, filepath)
},
/**
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index f838d9ab4..6a2132274 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -8,46 +8,52 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { ProjectID } from "./schema"
-import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
+import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
const log = Log.create({ service: "project" })
-export const Info = z
- .object({
- id: ProjectID.zod,
- worktree: z.string(),
- vcs: z.literal("git").optional(),
- name: z.string().optional(),
- icon: z
- .object({
- url: z.string().optional(),
- override: z.string().optional(),
- color: z.string().optional(),
- })
- .optional(),
- commands: z
- .object({
- start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
- })
- .optional(),
- time: z.object({
- created: z.number(),
- updated: z.number(),
- initialized: z.number().optional(),
- }),
- sandboxes: z.array(z.string()),
- })
- .meta({
- ref: "Project",
- })
-export type Info = z.infer<typeof Info>
+const ProjectVcs = Schema.Literal("git")
+
+const ProjectIcon = Schema.Struct({
+ url: Schema.optional(Schema.String),
+ override: Schema.optional(Schema.String),
+ color: Schema.optional(Schema.String),
+})
+
+const ProjectCommands = Schema.Struct({
+ start: Schema.optional(
+ Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
+ ),
+})
+
+const ProjectTime = Schema.Struct({
+ created: Schema.Number,
+ updated: Schema.Number,
+ initialized: Schema.optional(Schema.Number),
+})
+
+export const Info = Schema.Struct({
+ id: ProjectID,
+ worktree: Schema.String,
+ vcs: Schema.optional(ProjectVcs),
+ name: Schema.optional(Schema.String),
+ icon: Schema.optional(ProjectIcon),
+ commands: Schema.optional(ProjectCommands),
+ time: ProjectTime,
+ sandboxes: Schema.Array(Schema.String),
+})
+ .annotate({ identifier: "Project" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const Event = {
- Updated: BusEvent.define("project.updated", Info),
+ Updated: BusEvent.define("project.updated", Info.zod),
}
type Row = typeof ProjectTable.$inferSelect
@@ -58,7 +64,7 @@ export function fromRow(row: Row): Info {
return {
id: row.id,
worktree: row.worktree,
- vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
+ vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined,
name: row.name ?? undefined,
icon,
time: {
@@ -74,8 +80,8 @@ export function fromRow(row: Row): Info {
export const UpdateInput = z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
- icon: Info.shape.icon.optional(),
- commands: Info.shape.commands.optional(),
+ icon: zod(ProjectIcon).optional(),
+ commands: zod(ProjectCommands).optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
@@ -139,7 +145,7 @@ export const layer: Layer.Layer<
}),
)
- const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
+ const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
const resolveGitPath = (cwd: string, name: string) => {
if (!name) return cwd
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
index b1375a7b7..ba028f7e8 100644
--- a/packages/opencode/src/project/vcs.ts
+++ b/packages/opencode/src/project/vcs.ts
@@ -8,7 +8,6 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Log } from "@/util"
-import { Instance } from "./instance"
import z from "zod"
const log = Log.create({ service: "vcs" })
@@ -205,21 +204,17 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
- if (Instance.project.vcs !== "git") return []
+ const ctx = yield* InstanceState.context
+ if (ctx.project.vcs !== "git") return []
if (mode === "git") {
- return yield* track(
- fs,
- git,
- Instance.directory,
- (yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
- )
+ return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
- const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
+ const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
- return yield* compare(fs, git, Instance.directory, ref)
+ return yield* compare(fs, git, ctx.directory, ref)
}),
})
}),
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 711481d80..8f6e1556a 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -13,7 +13,6 @@ import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
-import { Instance } from "../project/instance"
import { InstallationVersion } from "../installation/version"
import { Flag } from "../flag/flag"
import { zod } from "@/util/effect-zod"
@@ -390,6 +389,17 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
}
}),
+ llmgateway: () =>
+ Effect.succeed({
+ autoload: false,
+ options: {
+ headers: {
+ "HTTP-Referer": "https://opencode.ai/",
+ "X-Title": "opencode",
+ "X-Source": "opencode",
+ },
+ },
+ }),
openrouter: () =>
Effect.succeed({
autoload: false,
@@ -526,6 +536,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
+ const directory = yield* InstanceState.directory
const aiGatewayHeaders = {
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
@@ -580,10 +591,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
log.info("gitlab model discovery starting", { instanceUrl })
- const result = await discoverWorkflowModels(
- { instanceUrl, getHeaders },
- { workingDirectory: Instance.directory },
- )
+ const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory })
if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
@@ -957,7 +965,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
family: model.family,
api: {
id: model.id,
- url: model.provider?.api ?? provider.api!,
+ url: model.provider?.api ?? provider.api ?? "",
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
@@ -970,10 +978,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
output: model.limit.output,
},
capabilities: {
- temperature: model.temperature,
- reasoning: model.reasoning,
- attachment: model.attachment,
- toolcall: model.tool_call,
+ temperature: model.temperature ?? false,
+ reasoning: model.reasoning ?? false,
+ attachment: model.attachment ?? false,
+ toolcall: model.tool_call ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? false,
audio: model.modalities?.input?.includes("audio") ?? false,
@@ -990,7 +998,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
},
interleaved: model.interleaved ?? false,
},
- release_date: model.release_date,
+ release_date: model.release_date ?? "",
variants: {},
}
@@ -1132,7 +1140,7 @@ const layer: Layer.Layer<
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
- url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
+ url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
},
status: model.status ?? existingModel?.status ?? "active",
name,
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 0ebd8bbf5..4ed43ce99 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -587,6 +587,12 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
+ if (model.providerID === "github-copilot") {
+ if (model.api.id.includes("opus-4.7")) {
+ return Object.fromEntries(["medium"].map((effort) => [effort, { reasoningEffort: effort }]))
+ }
+ }
+
if (adaptiveEfforts) {
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
@@ -629,6 +635,9 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
+ ...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
+ ? { display: "summarized" }
+ : {}),
},
},
]),
@@ -801,7 +810,7 @@ export function options(input: {
result["promptCacheKey"] = input.sessionID
}
- if (input.model.api.npm === "@openrouter/ai-sdk-provider") {
+ if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") {
result["usage"] = {
include: true,
}
@@ -938,7 +947,7 @@ export function smallOptions(model: Provider.Model) {
}
return { thinkingConfig: { thinkingBudget: 0 } }
}
- if (model.providerID === "openrouter") {
+ if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
if (model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}
diff --git a/packages/opencode/src/server/instance/trace.ts b/packages/opencode/src/server/instance/trace.ts
deleted file mode 100644
index b3adbb4c8..000000000
--- a/packages/opencode/src/server/instance/trace.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { Context } from "hono"
-import { Effect } from "effect"
-import { AppRuntime } from "../../effect/app-runtime"
-
-type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
-
-export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
- const url = new URL(c.req.url)
- return AppRuntime.runPromise(
- effect.pipe(
- Effect.withSpan(name, {
- attributes: {
- "http.method": c.req.method,
- "http.path": url.pathname,
- },
- }),
- ),
- )
-}
-
-export async function jsonRequest<C extends Context, A, E>(
- name: string,
- c: C,
- effect: (c: C) => Effect.gen.Return<A, E, AppEnv>,
-) {
- return c.json(
- await runRequest(
- name,
- c,
- Effect.gen(() => effect(c)),
- ),
- )
-}
diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts
index 9c1fd1f28..19a623cb0 100644
--- a/packages/opencode/src/server/proxy.ts
+++ b/packages/opencode/src/server/proxy.ts
@@ -130,13 +130,6 @@ export async function http(url: string | URL, extra: HeadersInit | undefined, re
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
- console.log("proxy http response", {
- method: req.method,
- request: req.url,
- url: String(url),
- status: res.status,
- statusText: res.statusText,
- })
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
diff --git a/packages/opencode/src/server/control/index.ts b/packages/opencode/src/server/routes/control/index.ts
index 737f958d6..60883274a 100644
--- a/packages/opencode/src/server/control/index.ts
+++ b/packages/opencode/src/server/routes/control/index.ts
@@ -6,13 +6,11 @@ import { ProviderID } from "@/provider/schema"
import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
import z from "zod"
-import { errors } from "../error"
-import { GlobalRoutes } from "../instance/global"
+import { errors } from "../../error"
export function ControlPlaneRoutes(): Hono {
const app = new Hono()
return app
- .route("/global", GlobalRoutes())
.put(
"/auth/:providerID",
describeRoute({
diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts
index 59369ef8e..9ff747b68 100644
--- a/packages/opencode/src/server/instance/workspace.ts
+++ b/packages/opencode/src/server/routes/control/workspace.ts
@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
-import { listAdaptors } from "../../control-plane/adaptors"
-import { Workspace } from "../../control-plane/workspace"
-import { Instance } from "../../project/instance"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { listAdaptors } from "@/control-plane/adaptors"
+import { Workspace } from "@/control-plane/workspace"
+import { Instance } from "@/project/instance"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
import { Log } from "@/util"
import { errorData } from "@/util/error"
diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/routes/global.ts
index 8208cf966..8208cf966 100644
--- a/packages/opencode/src/server/instance/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts
index 15c393fe5..7f368cd31 100644
--- a/packages/opencode/src/server/instance/config.ts
+++ b/packages/opencode/src/server/routes/instance/config.ts
@@ -1,11 +1,10 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { Config } from "../../config"
-import { Provider } from "../../provider"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
-import { AppRuntime } from "../../effect/app-runtime"
+import { Config } from "@/config"
+import { Provider } from "@/provider"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const ConfigRoutes = lazy(() =>
@@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
},
}),
validator("json", Config.Info),
- async (c) => {
- const config = c.req.valid("json")
- await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
- return c.json(config)
- },
+ async (c) =>
+ jsonRequest("ConfigRoutes.update", c, function* () {
+ const config = c.req.valid("json")
+ const cfg = yield* Config.Service
+ yield* cfg.update(config)
+ return config
+ }),
)
.get(
"/providers",
diff --git a/packages/opencode/src/server/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts
index 103d3d7cf..1d883bd88 100644
--- a/packages/opencode/src/server/instance/event.ts
+++ b/packages/opencode/src/server/routes/instance/event.ts
@@ -5,7 +5,7 @@ import { streamSSE } from "hono/streaming"
import { Log } from "@/util"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
-import { AsyncQueue } from "../../util/queue"
+import { AsyncQueue } from "@/util/queue"
const log = Log.create({ service: "server" })
diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts
index 6fe99a8c3..9c8649498 100644
--- a/packages/opencode/src/server/instance/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/experimental.ts
@@ -1,23 +1,22 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { ProviderID, ModelID } from "../../provider/schema"
-import { ToolRegistry } from "../../tool"
-import { Worktree } from "../../worktree"
-import { Instance } from "../../project/instance"
-import { Project } from "../../project"
-import { MCP } from "../../mcp"
-import { Session } from "../../session"
-import { Config } from "../../config"
-import { ConsoleState } from "../../config/console-state"
-import { Account } from "../../account/account"
-import { AccountID, OrgID } from "../../account/schema"
-import { AppRuntime } from "../../effect/app-runtime"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { ProviderID, ModelID } from "@/provider/schema"
+import { ToolRegistry } from "@/tool"
+import { Worktree } from "@/worktree"
+import { Instance } from "@/project/instance"
+import { Project } from "@/project"
+import { MCP } from "@/mcp"
+import { Session } from "@/session"
+import { Config } from "@/config"
+import { ConsoleState } from "@/config/console-state"
+import { Account } from "@/account/account"
+import { AccountID, OrgID } from "@/account/schema"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
import { Effect, Option } from "effect"
-import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
+import { jsonRequest, runRequest } from "./trace"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -50,28 +49,24 @@ export const ExperimentalRoutes = lazy(() =>
description: "Active Console provider metadata",
content: {
"application/json": {
- schema: resolver(ConsoleState),
+ schema: resolver(ConsoleState.zod),
},
},
},
},
}),
- async (c) => {
- const result = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const config = yield* Config.Service
- const account = yield* Account.Service
- const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
- concurrency: "unbounded",
- })
- return {
- ...state,
- switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
- }
- }),
- )
- return c.json(result)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.console.get", c, function* () {
+ const config = yield* Config.Service
+ const account = yield* Account.Service
+ const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
+ concurrency: "unbounded",
+ })
+ return {
+ ...state,
+ switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
+ }
+ }),
)
.get(
"/console/orgs",
@@ -90,28 +85,25 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
- async (c) => {
- const orgs = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const account = yield* Account.Service
- const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
- concurrency: "unbounded",
- })
- const info = Option.getOrUndefined(active)
- return groups.flatMap((group) =>
- group.orgs.map((org) => ({
- accountID: group.account.id,
- accountEmail: group.account.email,
- accountUrl: group.account.url,
- orgID: org.id,
- orgName: org.name,
- active: !!info && info.id === group.account.id && info.active_org_id === org.id,
- })),
- )
- }),
- )
- return c.json({ orgs })
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
+ const account = yield* Account.Service
+ const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
+ concurrency: "unbounded",
+ })
+ const info = Option.getOrUndefined(active)
+ const orgs = groups.flatMap((group) =>
+ group.orgs.map((org) => ({
+ accountID: group.account.id,
+ accountEmail: group.account.email,
+ accountUrl: group.account.url,
+ orgID: org.id,
+ orgName: org.name,
+ active: !!info && info.id === group.account.id && info.active_org_id === org.id,
+ })),
+ )
+ return { orgs }
+ }),
)
.post(
"/console/switch",
@@ -131,16 +123,13 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", ConsoleSwitchBody),
- async (c) => {
- const body = c.req.valid("json")
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const account = yield* Account.Service
- yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
- }),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
+ const body = c.req.valid("json")
+ const account = yield* Account.Service
+ yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
+ return true
+ }),
)
.get(
"/tool/ids",
@@ -161,15 +150,11 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
- async (c) => {
- const ids = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const registry = yield* ToolRegistry.Service
- return yield* registry.ids()
- }),
- )
- return c.json(ids)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
+ const registry = yield* ToolRegistry.Service
+ return yield* registry.ids()
+ }),
)
.get(
"/tool",
@@ -211,7 +196,9 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
- const tools = await AppRuntime.runPromise(
+ const tools = await runRequest(
+ "ExperimentalRoutes.tool.list",
+ c,
Effect.gen(function* () {
const agents = yield* Agent.Service
const registry = yield* ToolRegistry.Service
@@ -231,7 +218,6 @@ export const ExperimentalRoutes = lazy(() =>
)
},
)
- .route("/workspace", WorkspaceRoutes())
.post(
"/worktree",
describeRoute({
@@ -251,11 +237,12 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.CreateInput.optional()),
- async (c) => {
- const body = c.req.valid("json")
- const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
- return c.json(worktree)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
+ const body = c.req.valid("json")
+ const svc = yield* Worktree.Service
+ return yield* svc.create(body)
+ }),
)
.get(
"/worktree",
@@ -274,10 +261,11 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
- async (c) => {
- const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
- return c.json(sandboxes)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
+ const svc = yield* Project.Service
+ return yield* svc.sandboxes(Instance.project.id)
+ }),
)
.delete(
"/worktree",
@@ -298,14 +286,15 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.RemoveInput),
- async (c) => {
- const body = c.req.valid("json")
- await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
- await AppRuntime.runPromise(
- Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
+ const body = c.req.valid("json")
+ const worktree = yield* Worktree.Service
+ const project = yield* Project.Service
+ yield* worktree.remove(body)
+ yield* project.removeSandbox(Instance.project.id, body.directory)
+ return true
+ }),
)
.post(
"/worktree/reset",
@@ -326,11 +315,13 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.ResetInput),
- async (c) => {
- const body = c.req.valid("json")
- await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
+ const body = c.req.valid("json")
+ const svc = yield* Worktree.Service
+ yield* svc.reset(body)
+ return true
+ }),
)
.get(
"/session",
@@ -408,15 +399,10 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
- async (c) => {
- return c.json(
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const mcp = yield* MCP.Service
- return yield* mcp.resources()
- }),
- ),
- )
- },
+ async (c) =>
+ jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
+ const mcp = yield* MCP.Service
+ return yield* mcp.resources()
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts
index db5e22777..bbef679a8 100644
--- a/packages/opencode/src/server/instance/file.ts
+++ b/packages/opencode/src/server/routes/instance/file.ts
@@ -1,13 +1,12 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
-import { Effect } from "effect"
import z from "zod"
-import { AppRuntime } from "../../effect/app-runtime"
-import { File } from "../../file"
-import { Ripgrep } from "../../file/ripgrep"
-import { LSP } from "../../lsp"
-import { Instance } from "../../project/instance"
-import { lazy } from "../../util/lazy"
+import { File } from "@/file"
+import { Ripgrep } from "@/file/ripgrep"
+import { LSP } from "@/lsp"
+import { Instance } from "@/project/instance"
+import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
export const FileRoutes = lazy(() =>
new Hono()
@@ -34,13 +33,13 @@ export const FileRoutes = lazy(() =>
pattern: z.string(),
}),
),
- async (c) => {
- const pattern = c.req.valid("query").pattern
- const result = await AppRuntime.runPromise(
- Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
- )
- return c.json(result.items)
- },
+ async (c) =>
+ jsonRequest("FileRoutes.findText", c, function* () {
+ const pattern = c.req.valid("query").pattern
+ const svc = yield* Ripgrep.Service
+ const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
+ return result.items
+ }),
)
.get(
"/find/file",
@@ -68,25 +67,17 @@ export const FileRoutes = lazy(() =>
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
- async (c) => {
- const query = c.req.valid("query").query
- const dirs = c.req.valid("query").dirs
- const type = c.req.valid("query").type
- const limit = c.req.valid("query").limit
- const results = await AppRuntime.runPromise(
- Effect.gen(function* () {
- return yield* File.Service.use((svc) =>
- svc.search({
- query,
- limit: limit ?? 10,
- dirs: dirs !== "false",
- type,
- }),
- )
- }),
- )
- return c.json(results)
- },
+ async (c) =>
+ jsonRequest("FileRoutes.findFile", c, function* () {
+ const query = c.req.valid("query")
+ const svc = yield* File.Service
+ return yield* svc.search({
+ query: query.query,
+ limit: query.limit ?? 10,
+ dirs: query.dirs !== "false",
+ type: query.type,
+ })
+ }),
)
.get(
"/find/symbol",
@@ -138,15 +129,11 @@ export const FileRoutes = lazy(() =>
path: z.string(),
}),
),
- async (c) => {
- const path = c.req.valid("query").path
- const content = await AppRuntime.runPromise(
- Effect.gen(function* () {
- return yield* File.Service.use((svc) => svc.list(path))
- }),
- )
- return c.json(content)
- },
+ async (c) =>
+ jsonRequest("FileRoutes.list", c, function* () {
+ const svc = yield* File.Service
+ return yield* svc.list(c.req.valid("query").path)
+ }),
)
.get(
"/file/content",
@@ -171,15 +158,11 @@ export const FileRoutes = lazy(() =>
path: z.string(),
}),
),
- async (c) => {
- const path = c.req.valid("query").path
- const content = await AppRuntime.runPromise(
- Effect.gen(function* () {
- return yield* File.Service.use((svc) => svc.read(path))
- }),
- )
- return c.json(content)
- },
+ async (c) =>
+ jsonRequest("FileRoutes.read", c, function* () {
+ const svc = yield* File.Service
+ return yield* svc.read(c.req.valid("query").path)
+ }),
)
.get(
"/file/status",
@@ -198,13 +181,10 @@ export const FileRoutes = lazy(() =>
},
},
}),
- async (c) => {
- const content = await AppRuntime.runPromise(
- Effect.gen(function* () {
- return yield* File.Service.use((svc) => svc.status())
- }),
- )
- return c.json(content)
- },
+ async (c) =>
+ jsonRequest("FileRoutes.status", c, function* () {
+ const svc = yield* File.Service
+ return yield* svc.status()
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts
index 14aa94f9f..14aa94f9f 100644
--- a/packages/opencode/src/server/instance/httpapi/config.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts
diff --git a/packages/opencode/src/server/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/permission.ts
index ed8cb4e27..ed8cb4e27 100644
--- a/packages/opencode/src/server/instance/httpapi/permission.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/permission.ts
diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/project.ts
new file mode 100644
index 000000000..7d2d8462f
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/httpapi/project.ts
@@ -0,0 +1,62 @@
+import { Instance } from "@/project/instance"
+import { Project } from "@/project"
+import { Effect, Layer, Schema } from "effect"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const root = "/project"
+
+export const ProjectApi = HttpApi.make("project")
+ .add(
+ HttpApiGroup.make("project")
+ .add(
+ HttpApiEndpoint.get("list", root, {
+ success: Schema.Array(Project.Info),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "project.list",
+ summary: "List all projects",
+ description: "Get a list of projects that have been opened with OpenCode.",
+ }),
+ ),
+ HttpApiEndpoint.get("current", `${root}/current`, {
+ success: Project.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "project.current",
+ summary: "Get current project",
+ description: "Retrieve the currently active project that OpenCode is working with.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "project",
+ description: "Experimental HttpApi project routes.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "opencode experimental HttpApi",
+ version: "0.0.1",
+ description: "Experimental HttpApi surface for selected instance routes.",
+ }),
+ )
+
+export const projectHandlers = Layer.unwrap(
+ Effect.gen(function* () {
+ const svc = yield* Project.Service
+
+ const list = Effect.fn("ProjectHttpApi.list")(function* () {
+ return yield* svc.list()
+ })
+
+ const current = Effect.fn("ProjectHttpApi.current")(function* () {
+ return Instance.project
+ })
+
+ return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
+ handlers.handle("list", list).handle("current", current),
+ )
+ }),
+).pipe(Layer.provide(Project.defaultLayer))
diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts
index 67831a1fa..67831a1fa 100644
--- a/packages/opencode/src/server/instance/httpapi/provider.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/provider.ts
diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/question.ts
index 3192b530e..3192b530e 100644
--- a/packages/opencode/src/server/instance/httpapi/question.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/question.ts
diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 64332fd2a..d012e2c16 100644
--- a/packages/opencode/src/server/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -4,7 +4,6 @@ import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect"
-import { memoMap } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
@@ -12,8 +11,10 @@ import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util"
import { ConfigApi, configHandlers } from "./config"
import { PermissionApi, permissionHandlers } from "./permission"
+import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
+import { memoMap } from "@/effect/memo-map"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
@@ -108,11 +109,13 @@ const instance = HttpRouter.middleware()(
const QuestionSecured = QuestionApi.middleware(Authorization)
const PermissionSecured = PermissionApi.middleware(Authorization)
+const ProjectSecured = ProjectApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const ConfigSecured = ConfigApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
+ HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index 6a290093c..5cc51d27a 100644
--- a/packages/opencode/src/server/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -3,15 +3,15 @@ import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Context, Effect } from "effect"
import z from "zod"
-import { Format } from "../../format"
+import { Format } from "@/format"
import { TuiRoutes } from "./tui"
-import { Instance } from "../../project/instance"
-import { Vcs } from "../../project"
-import { Agent } from "../../agent/agent"
-import { Skill } from "../../skill"
-import { Global } from "../../global"
-import { LSP } from "../../lsp"
-import { Command } from "../../command"
+import { Instance } from "@/project/instance"
+import { Vcs } from "@/project"
+import { Agent } from "@/agent/agent"
+import { Skill } from "@/skill"
+import { Global } from "@/global"
+import { LSP } from "@/lsp"
+import { Command } from "@/command"
import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@/flag/flag"
@@ -26,18 +26,11 @@ import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
-import { WorkspaceRouterMiddleware } from "./middleware"
-import { AppRuntime } from "@/effect/app-runtime"
+import { InstanceMiddleware } from "./middleware"
+import { jsonRequest } from "./trace"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono()
- .use(WorkspaceRouterMiddleware(upgrade))
- .route("/project", ProjectRoutes())
- .route("/pty", PtyRoutes(upgrade))
- .route("/config", ConfigRoutes())
- .route("/experimental", ExperimentalRoutes())
- .route("/session", SessionRoutes())
- .route("/permission", PermissionRoutes())
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
@@ -52,9 +45,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.get("/provider/auth", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
+ app.get("/project", (c) => handler(c.req.raw, context))
+ app.get("/project/current", (c) => handler(c.req.raw, context))
}
return app
+ .route("/project", ProjectRoutes())
+ .route("/pty", PtyRoutes(upgrade))
+ .route("/config", ConfigRoutes())
+ .route("/experimental", ExperimentalRoutes())
+ .route("/session", SessionRoutes())
+ .route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/sync", SyncRoutes())
@@ -140,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
- async (c) => {
- return c.json(
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
- concurrency: 2,
- })
- return { branch, default_branch }
- }),
- ),
- )
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.vcs.get", c, function* () {
+ const vcs = yield* Vcs.Service
+ const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
+ concurrency: 2,
+ })
+ return { branch, default_branch }
+ }),
)
.get(
"/vcs/diff",
@@ -177,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
mode: Vcs.Mode,
}),
),
- async (c) => {
- return c.json(
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const vcs = yield* Vcs.Service
- return yield* vcs.diff(c.req.valid("query").mode)
- }),
- ),
- )
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
+ const vcs = yield* Vcs.Service
+ return yield* vcs.diff(c.req.valid("query").mode)
+ }),
)
.get(
"/command",
@@ -205,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
- async (c) => {
- const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
- return c.json(commands)
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.command.list", c, function* () {
+ const svc = yield* Command.Service
+ return yield* svc.list()
+ }),
)
.get(
"/agent",
@@ -227,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
- async (c) => {
- const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
- return c.json(modes)
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.agent.list", c, function* () {
+ const svc = yield* Agent.Service
+ return yield* svc.list()
+ }),
)
.get(
"/skill",
@@ -249,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
- async (c) => {
- const skills = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const skill = yield* Skill.Service
- return yield* skill.all()
- }),
- )
- return c.json(skills)
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.skill.list", c, function* () {
+ const skill = yield* Skill.Service
+ return yield* skill.all()
+ }),
)
.get(
"/lsp",
@@ -276,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
- async (c) => {
- const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
- return c.json(items)
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.lsp.status", c, function* () {
+ const lsp = yield* LSP.Service
+ return yield* lsp.status()
+ }),
)
.get(
"/formatter",
@@ -298,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
- async (c) => {
- return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
- },
+ async (c) =>
+ jsonRequest("InstanceRoutes.formatter.status", c, function* () {
+ const svc = yield* Format.Service
+ return yield* svc.status()
+ }),
)
}
diff --git a/packages/opencode/src/server/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts
index f6e6f1edd..ce4722933 100644
--- a/packages/opencode/src/server/instance/mcp.ts
+++ b/packages/opencode/src/server/routes/instance/mcp.ts
@@ -1,13 +1,12 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { MCP } from "../../mcp"
-import { Config } from "../../config"
-import { ConfigMCP } from "../../config/mcp"
-import { AppRuntime } from "../../effect/app-runtime"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { MCP } from "@/mcp"
+import { ConfigMCP } from "@/config/mcp"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
import { Effect } from "effect"
+import { jsonRequest, runRequest } from "./trace"
export const McpRoutes = lazy(() =>
new Hono()
@@ -28,9 +27,11 @@ export const McpRoutes = lazy(() =>
},
},
}),
- async (c) => {
- return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
- },
+ async (c) =>
+ jsonRequest("McpRoutes.status", c, function* () {
+ const mcp = yield* MCP.Service
+ return yield* mcp.status()
+ }),
)
.post(
"/",
@@ -54,14 +55,16 @@ export const McpRoutes = lazy(() =>
"json",
z.object({
name: z.string(),
- config: ConfigMCP.Info,
+ config: ConfigMCP.Info.zod,
}),
),
- async (c) => {
- const { name, config } = c.req.valid("json")
- const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
- return c.json(result.status)
- },
+ async (c) =>
+ jsonRequest("McpRoutes.add", c, function* () {
+ const { name, config } = c.req.valid("json")
+ const mcp = yield* MCP.Service
+ const result = yield* mcp.add(name, config)
+ return result.status
+ }),
)
.post(
"/:name/auth",
@@ -87,7 +90,9 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
- const result = await AppRuntime.runPromise(
+ const result = await runRequest(
+ "McpRoutes.auth.start",
+ c,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
@@ -129,12 +134,13 @@ export const McpRoutes = lazy(() =>
code: z.string().describe("Authorization code from OAuth callback"),
}),
),
- async (c) => {
- const name = c.req.param("name")
- const { code } = c.req.valid("json")
- const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
- return c.json(status)
- },
+ async (c) =>
+ jsonRequest("McpRoutes.auth.callback", c, function* () {
+ const name = c.req.param("name")
+ const { code } = c.req.valid("json")
+ const mcp = yield* MCP.Service
+ return yield* mcp.finishAuth(name, code)
+ }),
)
.post(
"/:name/auth/authenticate",
@@ -156,7 +162,9 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
- const result = await AppRuntime.runPromise(
+ const result = await runRequest(
+ "McpRoutes.auth.authenticate",
+ c,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
@@ -191,11 +199,13 @@ export const McpRoutes = lazy(() =>
...errors(404),
},
}),
- async (c) => {
- const name = c.req.param("name")
- await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
- return c.json({ success: true as const })
- },
+ async (c) =>
+ jsonRequest("McpRoutes.auth.remove", c, function* () {
+ const name = c.req.param("name")
+ const mcp = yield* MCP.Service
+ yield* mcp.removeAuth(name)
+ return { success: true as const }
+ }),
)
.post(
"/:name/connect",
@@ -214,11 +224,13 @@ export const McpRoutes = lazy(() =>
},
}),
validator("param", z.object({ name: z.string() })),
- async (c) => {
- const { name } = c.req.valid("param")
- await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("McpRoutes.connect", c, function* () {
+ const { name } = c.req.valid("param")
+ const mcp = yield* MCP.Service
+ yield* mcp.connect(name)
+ return true
+ }),
)
.post(
"/:name/disconnect",
@@ -237,10 +249,12 @@ export const McpRoutes = lazy(() =>
},
}),
validator("param", z.object({ name: z.string() })),
- async (c) => {
- const { name } = c.req.valid("param")
- await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("McpRoutes.disconnect", c, function* () {
+ const { name } = c.req.valid("param")
+ const mcp = yield* MCP.Service
+ yield* mcp.disconnect(name)
+ return true
+ }),
),
)
diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts
new file mode 100644
index 000000000..b963268d6
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/middleware.ts
@@ -0,0 +1,35 @@
+import type { MiddlewareHandler } from "hono"
+import { Instance } from "@/project/instance"
+import { InstanceBootstrap } from "@/project/bootstrap"
+import { AppRuntime } from "@/effect/app-runtime"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
+import { WorkspaceID } from "@/control-plane/schema"
+
+export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
+ return async (c, next) => {
+ const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+ const directory = AppFileSystem.resolve(
+ (() => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+ })(),
+ )
+
+ return WorkspaceContext.provide({
+ workspaceID,
+ async fn() {
+ return Instance.provide({
+ directory,
+ init: () => AppRuntime.runPromise(InstanceBootstrap),
+ async fn() {
+ return next()
+ },
+ })
+ },
+ })
+ }
+}
diff --git a/packages/opencode/src/server/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts
index b8c224414..c18f4734b 100644
--- a/packages/opencode/src/server/instance/permission.ts
+++ b/packages/opencode/src/server/routes/instance/permission.ts
@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { AppRuntime } from "@/effect/app-runtime"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
export const PermissionRoutes = lazy(() =>
new Hono()
@@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
}),
),
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
- async (c) => {
- const params = c.req.valid("param")
- const json = c.req.valid("json")
- await AppRuntime.runPromise(
- Permission.Service.use((svc) =>
- svc.reply({
- requestID: params.requestID,
- reply: json.reply,
- message: json.message,
- }),
- ),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("PermissionRoutes.reply", c, function* () {
+ const params = c.req.valid("param")
+ const json = c.req.valid("json")
+ const svc = yield* Permission.Service
+ yield* svc.reply({
+ requestID: params.requestID,
+ reply: json.reply,
+ message: json.message,
+ })
+ return true
+ }),
)
.get(
"/",
@@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() =>
},
},
}),
- async (c) => {
- const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
- return c.json(permissions)
- },
+ async (c) =>
+ jsonRequest("PermissionRoutes.list", c, function* () {
+ const svc = yield* Permission.Service
+ return yield* svc.list()
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts
index eea741596..5acef6d78 100644
--- a/packages/opencode/src/server/instance/project.ts
+++ b/packages/opencode/src/server/routes/instance/project.ts
@@ -1,14 +1,15 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
-import { Instance } from "../../project/instance"
-import { Project } from "../../project"
+import { Instance } from "@/project/instance"
+import { Project } from "@/project"
import z from "zod"
-import { ProjectID } from "../../project/schema"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
-import { InstanceBootstrap } from "../../project/bootstrap"
+import { ProjectID } from "@/project/schema"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
+import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
+import { jsonRequest, runRequest } from "./trace"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -23,7 +24,7 @@ export const ProjectRoutes = lazy(() =>
description: "List of projects",
content: {
"application/json": {
- schema: resolver(Project.Info.array()),
+ schema: resolver(Project.Info.zod.array()),
},
},
},
@@ -45,7 +46,7 @@ export const ProjectRoutes = lazy(() =>
description: "Current project information",
content: {
"application/json": {
- schema: resolver(Project.Info),
+ schema: resolver(Project.Info.zod),
},
},
},
@@ -66,7 +67,7 @@ export const ProjectRoutes = lazy(() =>
description: "Project information after git initialization",
content: {
"application/json": {
- schema: resolver(Project.Info),
+ schema: resolver(Project.Info.zod),
},
},
},
@@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const dir = Instance.directory
const prev = Instance.project
- const next = await AppRuntime.runPromise(
+ const next = await runRequest(
+ "ProjectRoutes.initGit",
+ c,
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
)
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
@@ -99,7 +102,7 @@ export const ProjectRoutes = lazy(() =>
description: "Updated project information",
content: {
"application/json": {
- schema: resolver(Project.Info),
+ schema: resolver(Project.Info.zod),
},
},
},
@@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() =>
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.UpdateInput.omit({ projectID: true })),
- async (c) => {
- const projectID = c.req.valid("param").projectID
- const body = c.req.valid("json")
- const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
- return c.json(project)
- },
+ async (c) =>
+ jsonRequest("ProjectRoutes.update", c, function* () {
+ const projectID = c.req.valid("param").projectID
+ const body = c.req.valid("json")
+ const svc = yield* Project.Service
+ return yield* svc.update({ ...body, projectID })
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts
index a81ae00d5..617980e39 100644
--- a/packages/opencode/src/server/instance/provider.ts
+++ b/packages/opencode/src/server/routes/instance/provider.ts
@@ -1,16 +1,16 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { Config } from "../../config"
-import { Provider } from "../../provider"
-import { ModelsDev } from "../../provider"
-import { ProviderAuth } from "../../provider"
-import { ProviderID } from "../../provider/schema"
-import { AppRuntime } from "../../effect/app-runtime"
+import { Config } from "@/config"
+import { Provider } from "@/provider"
+import { ModelsDev } from "@/provider"
+import { ProviderAuth } from "@/provider"
+import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
import { Effect } from "effect"
+import { jsonRequest } from "./trace"
export const ProviderRoutes = lazy(() =>
new Hono()
@@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() =>
},
},
}),
- async (c) => {
- const result = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const svc = yield* Provider.Service
- const cfg = yield* Config.Service
- const config = yield* cfg.get()
- const all = yield* Effect.promise(() => ModelsDev.get())
- const disabled = new Set(config.disabled_providers ?? [])
- const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
- const filtered: Record<string, (typeof all)[string]> = {}
- for (const [key, value] of Object.entries(all)) {
- if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
- filtered[key] = value
- }
+ async (c) =>
+ jsonRequest("ProviderRoutes.list", c, function* () {
+ const svc = yield* Provider.Service
+ const cfg = yield* Config.Service
+ const config = yield* cfg.get()
+ const all = yield* Effect.promise(() => ModelsDev.get())
+ const disabled = new Set(config.disabled_providers ?? [])
+ const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
+ const filtered: Record<string, (typeof all)[string]> = {}
+ for (const [key, value] of Object.entries(all)) {
+ if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
+ filtered[key] = value
}
- const connected = yield* svc.list()
- const providers = Object.assign(
- mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
- connected,
- )
- return {
- all: Object.values(providers),
- default: Provider.defaultModelIDs(providers),
- connected: Object.keys(connected),
- }
- }),
- )
- return c.json({
- all: result.all,
- default: result.default,
- connected: result.connected,
- })
- },
+ }
+ const connected = yield* svc.list()
+ const providers = Object.assign(
+ mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
+ connected,
+ )
+ return {
+ all: Object.values(providers),
+ default: Provider.defaultModelIDs(providers),
+ connected: Object.keys(connected),
+ }
+ }),
)
.get(
"/auth",
@@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() =>
},
},
}),
- async (c) => {
- return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
- },
+ async (c) =>
+ jsonRequest("ProviderRoutes.auth", c, function* () {
+ const svc = yield* ProviderAuth.Service
+ return yield* svc.methods()
+ }),
)
.post(
"/:providerID/oauth/authorize",
@@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
}),
),
validator("json", ProviderAuth.AuthorizeInput.zod),
- async (c) => {
- const providerID = c.req.valid("param").providerID
- const { method, inputs } = c.req.valid("json")
- const result = await AppRuntime.runPromise(
- ProviderAuth.Service.use((svc) =>
- svc.authorize({
- providerID,
- method,
- inputs,
- }),
- ),
- )
- return c.json(result)
- },
+ async (c) =>
+ jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
+ const providerID = c.req.valid("param").providerID
+ const { method, inputs } = c.req.valid("json")
+ const svc = yield* ProviderAuth.Service
+ return yield* svc.authorize({
+ providerID,
+ method,
+ inputs,
+ })
+ }),
)
.post(
"/:providerID/oauth/callback",
@@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
}),
),
validator("json", ProviderAuth.CallbackInput.zod),
- async (c) => {
- const providerID = c.req.valid("param").providerID
- const { method, code } = c.req.valid("json")
- await AppRuntime.runPromise(
- ProviderAuth.Service.use((svc) =>
- svc.callback({
- providerID,
- method,
- code,
- }),
- ),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
+ const providerID = c.req.valid("param").providerID
+ const { method, code } = c.req.valid("json")
+ const svc = yield* ProviderAuth.Service
+ yield* svc.callback({
+ providerID,
+ method,
+ code,
+ })
+ return true
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts
index 794372512..a25b66e9f 100644
--- a/packages/opencode/src/server/instance/pty.ts
+++ b/packages/opencode/src/server/routes/instance/pty.ts
@@ -6,8 +6,9 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
-import { NotFoundError } from "../../storage"
-import { errors } from "../error"
+import { NotFoundError } from "@/storage"
+import { errors } from "../../error"
+import { jsonRequest, runRequest } from "./trace"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
@@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
},
}),
- async (c) => {
- return c.json(
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const pty = yield* Pty.Service
- return yield* pty.list()
- }),
- ),
- )
- },
+ async (c) =>
+ jsonRequest("PtyRoutes.list", c, function* () {
+ const pty = yield* Pty.Service
+ return yield* pty.list()
+ }),
)
.post(
"/",
@@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("json", Pty.CreateInput),
- async (c) => {
- const info = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const pty = yield* Pty.Service
- return yield* pty.create(c.req.valid("json"))
- }),
- )
- return c.json(info)
- },
+ async (c) =>
+ jsonRequest("PtyRoutes.create", c, function* () {
+ const pty = yield* Pty.Service
+ return yield* pty.create(c.req.valid("json"))
+ }),
)
.get(
"/:ptyID",
@@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
- const info = await AppRuntime.runPromise(
+ const info = await runRequest(
+ "PtyRoutes.get",
+ c,
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
@@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
- async (c) => {
- const info = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const pty = yield* Pty.Service
- return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
- }),
- )
- return c.json(info)
- },
+ async (c) =>
+ jsonRequest("PtyRoutes.update", c, function* () {
+ const pty = yield* Pty.Service
+ return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
+ }),
)
.delete(
"/:ptyID",
@@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
- async (c) => {
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const pty = yield* Pty.Service
- yield* pty.remove(c.req.valid("param").ptyID)
- }),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("PtyRoutes.remove", c, function* () {
+ const pty = yield* Pty.Service
+ yield* pty.remove(c.req.valid("param").ptyID)
+ return true
+ }),
)
.get(
"/:ptyID/connect",
@@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
})()
let handler: Handler | undefined
if (
- !(await AppRuntime.runPromise(
+ !(await runRequest(
+ "PtyRoutes.connect",
+ c,
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
@@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
- }),
+ }).pipe(Effect.withSpan("PtyRoutes.connect.open")),
)
ready = true
for (const msg of pending) handler?.onMessage(msg)
diff --git a/packages/opencode/src/server/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts
index 0f61a1867..51ecb48cc 100644
--- a/packages/opencode/src/server/instance/question.ts
+++ b/packages/opencode/src/server/routes/instance/question.ts
@@ -2,11 +2,11 @@ import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
-import { Question } from "../../question"
-import { AppRuntime } from "@/effect/app-runtime"
+import { Question } from "@/question"
import z from "zod"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
+import { jsonRequest } from "./trace"
const Reply = z.object({
answers: Question.Answer.zod
@@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() =>
},
},
}),
- async (c) => {
- const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
- return c.json(questions)
- },
+ async (c) =>
+ jsonRequest("QuestionRoutes.list", c, function* () {
+ const svc = yield* Question.Service
+ return yield* svc.list()
+ }),
)
.post(
"/:requestID/reply",
@@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
}),
),
validator("json", Reply),
- async (c) => {
- const params = c.req.valid("param")
- const json = c.req.valid("json")
- await AppRuntime.runPromise(
- Question.Service.use((svc) =>
- svc.reply({
- requestID: params.requestID,
- answers: json.answers,
- }),
- ),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("QuestionRoutes.reply", c, function* () {
+ const params = c.req.valid("param")
+ const json = c.req.valid("json")
+ const svc = yield* Question.Service
+ yield* svc.reply({
+ requestID: params.requestID,
+ answers: json.answers,
+ })
+ return true
+ }),
)
.post(
"/:requestID/reject",
@@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
requestID: QuestionID.zod,
}),
),
- async (c) => {
- const params = c.req.valid("param")
- await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("QuestionRoutes.reject", c, function* () {
+ const params = c.req.valid("param")
+ const svc = yield* Question.Service
+ yield* svc.reject(params.requestID)
+ return true
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts
index 1511e99e8..bf713935b 100644
--- a/packages/opencode/src/server/instance/session.ts
+++ b/packages/opencode/src/server/routes/instance/session.ts
@@ -3,30 +3,29 @@ import { stream } from "hono/streaming"
import { describeRoute, validator, resolver } from "hono-openapi"
import { SessionID, MessageID, PartID } from "@/session/schema"
import z from "zod"
-import { Session } from "../../session"
-import { MessageV2 } from "../../session/message-v2"
-import { SessionPrompt } from "../../session/prompt"
+import { Session } from "@/session"
+import { MessageV2 } from "@/session/message-v2"
+import { SessionPrompt } from "@/session/prompt"
import { SessionRunState } from "@/session/run-state"
-import { SessionCompaction } from "../../session/compaction"
-import { SessionRevert } from "../../session/revert"
+import { SessionCompaction } from "@/session/compaction"
+import { SessionRevert } from "@/session/revert"
import { SessionShare } from "@/share"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
-import { Todo } from "../../session/todo"
+import { Todo } from "@/session/todo"
import { Effect } from "effect"
-import { AppRuntime } from "../../effect/app-runtime"
-import { Agent } from "../../agent/agent"
+import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot"
-import { Command } from "../../command"
-import { Log } from "../../util"
+import { Command } from "@/command"
+import { Log } from "@/util"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
-import { Bus } from "../../bus"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
+import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/shared/util/error"
-import { jsonRequest } from "./trace"
+import { jsonRequest, runRequest } from "./trace"
const log = Log.create({ service: "server" })
@@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
},
}),
validator("json", Session.CreateInput),
- async (c) => {
- const body = c.req.valid("json") ?? {}
- const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
- return c.json(session)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.create", c, function* () {
+ const body = c.req.valid("json") ?? {}
+ const svc = yield* SessionShare.Service
+ return yield* svc.create(body)
+ }),
)
.delete(
"/:sessionID",
@@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
sessionID: Session.RemoveInput,
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.delete", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const svc = yield* Session.Service
+ yield* svc.remove(sessionID)
+ return true
+ }),
)
.patch(
"/:sessionID",
@@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
.optional(),
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const updates = c.req.valid("json")
- const session = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const session = yield* Session.Service
- const current = yield* session.get(sessionID)
+ async (c) =>
+ jsonRequest("SessionRoutes.update", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const updates = c.req.valid("json")
+ const session = yield* Session.Service
+ const current = yield* session.get(sessionID)
- if (updates.title !== undefined) {
- yield* session.setTitle({ sessionID, title: updates.title })
- }
- if (updates.permission !== undefined) {
- yield* session.setPermission({
- sessionID,
- permission: Permission.merge(current.permission ?? [], updates.permission),
- })
- }
- if (updates.time?.archived !== undefined) {
- yield* session.setArchived({ sessionID, time: updates.time.archived })
- }
+ if (updates.title !== undefined) {
+ yield* session.setTitle({ sessionID, title: updates.title })
+ }
+ if (updates.permission !== undefined) {
+ yield* session.setPermission({
+ sessionID,
+ permission: Permission.merge(current.permission ?? [], updates.permission),
+ })
+ }
+ if (updates.time?.archived !== undefined) {
+ yield* session.setArchived({ sessionID, time: updates.time.archived })
+ }
- return yield* session.get(sessionID)
- }),
- )
- return c.json(session)
- },
+ return yield* session.get(sessionID)
+ }),
)
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
.post(
@@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod,
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const body = c.req.valid("json")
- await AppRuntime.runPromise(
- SessionPrompt.Service.use((svc) =>
- svc.command({
- sessionID,
- messageID: body.messageID,
- model: body.providerID + "/" + body.modelID,
- command: Command.Default.INIT,
- arguments: "",
- }),
- ),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.init", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const body = c.req.valid("json")
+ const svc = yield* SessionPrompt.Service
+ yield* svc.command({
+ sessionID,
+ messageID: body.messageID,
+ model: body.providerID + "/" + body.modelID,
+ command: Command.Default.INIT,
+ arguments: "",
+ })
+ return true
+ }),
)
.post(
"/:sessionID/fork",
@@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", Session.ForkInput.omit({ sessionID: true })),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const body = c.req.valid("json")
- const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
- return c.json(result)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.fork", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const body = c.req.valid("json")
+ const svc = yield* Session.Service
+ return yield* svc.fork({ ...body, sessionID })
+ }),
)
.post(
"/:sessionID/abort",
@@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
- async (c) => {
- await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.abort", c, function* () {
+ const svc = yield* SessionPrompt.Service
+ yield* svc.cancel(c.req.valid("param").sessionID)
+ return true
+ }),
)
.post(
"/:sessionID/share",
@@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const session = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const share = yield* SessionShare.Service
- const session = yield* Session.Service
- yield* share.share(sessionID)
- return yield* session.get(sessionID)
- }),
- )
- return c.json(session)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.share", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const share = yield* SessionShare.Service
+ const session = yield* Session.Service
+ yield* share.share(sessionID)
+ return yield* session.get(sessionID)
+ }),
)
.get(
"/:sessionID/diff",
@@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
messageID: SessionSummary.DiffInput.shape.messageID,
}),
),
- async (c) => {
- const query = c.req.valid("query")
- const params = c.req.valid("param")
- const result = await AppRuntime.runPromise(
- SessionSummary.Service.use((summary) =>
- summary.diff({
- sessionID: params.sessionID,
- messageID: query.messageID,
- }),
- ),
- )
- return c.json(result)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.diff", c, function* () {
+ const query = c.req.valid("query")
+ const params = c.req.valid("param")
+ const summary = yield* SessionSummary.Service
+ return yield* summary.diff({
+ sessionID: params.sessionID,
+ messageID: query.messageID,
+ })
+ }),
)
.delete(
"/:sessionID/share",
@@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const session = await AppRuntime.runPromise(
- Effect.gen(function* () {
- const share = yield* SessionShare.Service
- const session = yield* Session.Service
- yield* share.unshare(sessionID)
- return yield* session.get(sessionID)
- }),
- )
- return c.json(session)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.unshare", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const share = yield* SessionShare.Service
+ const session = yield* Session.Service
+ yield* share.unshare(sessionID)
+ return yield* session.get(sessionID)
+ }),
)
.post(
"/:sessionID/summarize",
@@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
auto: z.boolean().optional().default(false),
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const body = c.req.valid("json")
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const session = yield* Session.Service
- const revert = yield* SessionRevert.Service
- const compact = yield* SessionCompaction.Service
- const prompt = yield* SessionPrompt.Service
- const agent = yield* Agent.Service
+ async (c) =>
+ jsonRequest("SessionRoutes.summarize", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const body = c.req.valid("json")
+ const session = yield* Session.Service
+ const revert = yield* SessionRevert.Service
+ const compact = yield* SessionCompaction.Service
+ const prompt = yield* SessionPrompt.Service
+ const agent = yield* Agent.Service
- yield* revert.cleanup(yield* session.get(sessionID))
- const msgs = yield* session.messages({ sessionID })
- const defaultAgent = yield* agent.defaultAgent()
- let currentAgent = defaultAgent
- for (let i = msgs.length - 1; i >= 0; i--) {
- const info = msgs[i].info
- if (info.role === "user") {
- currentAgent = info.agent || defaultAgent
- break
- }
+ yield* revert.cleanup(yield* session.get(sessionID))
+ const msgs = yield* session.messages({ sessionID })
+ const defaultAgent = yield* agent.defaultAgent()
+ let currentAgent = defaultAgent
+ for (let i = msgs.length - 1; i >= 0; i--) {
+ const info = msgs[i].info
+ if (info.role === "user") {
+ currentAgent = info.agent || defaultAgent
+ break
}
+ }
- yield* compact.create({
- sessionID,
- agent: currentAgent,
- model: {
- providerID: body.providerID,
- modelID: body.modelID,
- },
- auto: body.auto,
- })
- yield* prompt.loop({ sessionID })
- }),
- )
- return c.json(true)
- },
+ yield* compact.create({
+ sessionID,
+ agent: currentAgent,
+ model: {
+ providerID: body.providerID,
+ modelID: body.modelID,
+ },
+ auto: body.auto,
+ })
+ yield* prompt.loop({ sessionID })
+ return true
+ }),
)
.get(
"/:sessionID/message",
@@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined || query.limit === 0) {
- const messages = await AppRuntime.runPromise(
+ const messages = await runRequest(
+ "SessionRoutes.messages",
+ c,
Effect.gen(function* () {
const session = yield* Session.Service
yield* session.get(sessionID)
@@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod,
}),
),
- async (c) => {
- const params = c.req.valid("param")
- await AppRuntime.runPromise(
- Effect.gen(function* () {
- const state = yield* SessionRunState.Service
- const session = yield* Session.Service
- yield* state.assertNotBusy(params.sessionID)
- yield* session.removeMessage({
- sessionID: params.sessionID,
- messageID: params.messageID,
- })
- }),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.deleteMessage", c, function* () {
+ const params = c.req.valid("param")
+ const state = yield* SessionRunState.Service
+ const session = yield* Session.Service
+ yield* state.assertNotBusy(params.sessionID)
+ yield* session.removeMessage({
+ sessionID: params.sessionID,
+ messageID: params.messageID,
+ })
+ return true
+ }),
)
.delete(
"/:sessionID/message/:messageID/part/:partID",
@@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
partID: PartID.zod,
}),
),
- async (c) => {
- const params = c.req.valid("param")
- await AppRuntime.runPromise(
- Session.Service.use((svc) =>
- svc.removePart({
- sessionID: params.sessionID,
- messageID: params.messageID,
- partID: params.partID,
- }),
- ),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.deletePart", c, function* () {
+ const params = c.req.valid("param")
+ const svc = yield* Session.Service
+ yield* svc.removePart({
+ sessionID: params.sessionID,
+ messageID: params.messageID,
+ partID: params.partID,
+ })
+ return true
+ }),
)
.patch(
"/:sessionID/message/:messageID/part/:partID",
@@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() =>
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
- const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
- return c.json(part)
+ return jsonRequest("SessionRoutes.updatePart", c, function* () {
+ const svc = yield* Session.Service
+ return yield* svc.updatePart(body)
+ })
},
)
.post(
@@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
- const msg = await AppRuntime.runPromise(
+ const msg = await runRequest(
+ "SessionRoutes.prompt",
+ c,
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
)
void stream.write(JSON.stringify(msg))
@@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
- void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch(
- (err) => {
- log.error("prompt_async failed", { sessionID, error: err })
- void Bus.publish(Session.Event.Error, {
- sessionID,
- error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
- })
- },
- )
+ void runRequest(
+ "SessionRoutes.prompt_async",
+ c,
+ SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
+ ).catch((err) => {
+ log.error("prompt_async failed", { sessionID, error: err })
+ void Bus.publish(Session.Event.Error, {
+ sessionID,
+ error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
+ })
+ })
return c.body(null, 204)
},
@@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const body = c.req.valid("json")
- const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
- return c.json(msg)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.command", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const body = c.req.valid("json")
+ const svc = yield* SessionPrompt.Service
+ return yield* svc.command({ ...body, sessionID })
+ }),
)
.post(
"/:sessionID/shell",
@@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const body = c.req.valid("json")
- const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
- return c.json(msg)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.shell", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const body = c.req.valid("json")
+ const svc = yield* SessionPrompt.Service
+ return yield* svc.shell({ ...body, sessionID })
+ }),
)
.post(
"/:sessionID/revert",
@@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
- const session = await AppRuntime.runPromise(
- SessionRevert.Service.use((svc) =>
- svc.revert({
- sessionID,
- ...c.req.valid("json"),
- }),
- ),
- )
- return c.json(session)
+ return jsonRequest("SessionRoutes.revert", c, function* () {
+ const svc = yield* SessionRevert.Service
+ return yield* svc.revert({
+ sessionID,
+ ...c.req.valid("json"),
+ })
+ })
},
)
.post(
@@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
- async (c) => {
- const sessionID = c.req.valid("param").sessionID
- const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
- return c.json(session)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.unrevert", c, function* () {
+ const sessionID = c.req.valid("param").sessionID
+ const svc = yield* SessionRevert.Service
+ return yield* svc.unrevert({ sessionID })
+ }),
)
.post(
"/:sessionID/permissions/:permissionID",
@@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", z.object({ response: Permission.Reply.zod })),
- async (c) => {
- const params = c.req.valid("param")
- await AppRuntime.runPromise(
- Permission.Service.use((svc) =>
- svc.reply({
- requestID: params.permissionID,
- reply: c.req.valid("json").response,
- }),
- ),
- )
- return c.json(true)
- },
+ async (c) =>
+ jsonRequest("SessionRoutes.permissionRespond", c, function* () {
+ const params = c.req.valid("param")
+ const svc = yield* Permission.Service
+ yield* svc.reply({
+ requestID: params.permissionID,
+ reply: c.req.valid("json").response,
+ })
+ return true
+ }),
),
)
diff --git a/packages/opencode/src/server/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts
index ac43b638e..b124cd875 100644
--- a/packages/opencode/src/server/instance/sync.ts
+++ b/packages/opencode/src/server/routes/instance/sync.ts
@@ -6,7 +6,9 @@ import { Database, asc, and, not, or, lte, eq } from "@/storage"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import { Log } from "@/util"
-import { errors } from "../error"
+import { startWorkspaceSyncing } from "@/control-plane/workspace"
+import { Instance } from "@/project/instance"
+import { errors } from "../../error"
const ReplayEvent = z.object({
id: z.string(),
@@ -21,6 +23,28 @@ const log = Log.create({ service: "server.sync" })
export const SyncRoutes = lazy(() =>
new Hono()
.post(
+ "/start",
+ describeRoute({
+ summary: "Start workspace sync",
+ description: "Start sync loops for workspaces in the current project that have active sessions.",
+ operationId: "sync.start",
+ responses: {
+ 200: {
+ description: "Workspace sync started",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ startWorkspaceSyncing(Instance.project.id)
+ return c.json(true)
+ },
+ )
+ .post(
"/replay",
describeRoute({
summary: "Replay sync events",
@@ -75,7 +99,7 @@ export const SyncRoutes = lazy(() =>
})
},
)
- .get(
+ .post(
"/history",
describeRoute({
summary: "List sync events",
diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts
new file mode 100644
index 000000000..4c7119ef3
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/trace.ts
@@ -0,0 +1,59 @@
+import type { Context } from "hono"
+import { Effect } from "effect"
+import { AppRuntime } from "@/effect/app-runtime"
+
+type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
+
+// Build the base span attributes for an HTTP handler: method, path, and every
+// matched route param. Names follow OTel attribute-naming guidance:
+// domain-first (`session.id`, `message.id`, …) so they match the existing
+// OTel `session.id` semantic convention and the bare `message.id` we
+// already emit from Tool.execute. Non-standard route params fall back to
+// `opencode.<name>` since those are internal implementation details
+// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
+export interface RequestLike {
+ readonly req: {
+ readonly method: string
+ readonly url: string
+ param(): Record<string, string>
+ }
+}
+
+// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
+// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
+// other param is namespaced under `opencode.` to avoid colliding with
+// standard conventions.
+export function paramToAttributeKey(key: string): string {
+ const m = key.match(/^(.+)ID$/)
+ if (m) return `${m[1].toLowerCase()}.id`
+ return `opencode.${key}`
+}
+
+export function requestAttributes(c: RequestLike): Record<string, string> {
+ const attributes: Record<string, string> = {
+ "http.method": c.req.method,
+ "http.path": new URL(c.req.url).pathname,
+ }
+ for (const [key, value] of Object.entries(c.req.param())) {
+ attributes[paramToAttributeKey(key)] = value
+ }
+ return attributes
+}
+
+export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
+ return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
+}
+
+export async function jsonRequest<C extends Context, A, E>(
+ name: string,
+ c: C,
+ effect: (c: C) => Effect.gen.Return<A, E, AppEnv>,
+) {
+ return c.json(
+ await runRequest(
+ name,
+ c,
+ Effect.gen(() => effect(c)),
+ ),
+ )
+}
diff --git a/packages/opencode/src/server/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts
index 0073ef98c..d6add67b9 100644
--- a/packages/opencode/src/server/instance/tui.ts
+++ b/packages/opencode/src/server/routes/instance/tui.ts
@@ -1,13 +1,13 @@
import { Hono, type Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
-import { Bus } from "../../bus"
-import { Session } from "../../session"
+import { Bus } from "@/bus"
+import { Session } from "@/session"
import { TuiEvent } from "@/cli/cmd/tui/event"
-import { AppRuntime } from "@/effect/app-runtime"
-import { AsyncQueue } from "../../util/queue"
-import { errors } from "../error"
-import { lazy } from "../../util/lazy"
+import { AsyncQueue } from "@/util/queue"
+import { errors } from "../../error"
+import { lazy } from "@/util/lazy"
+import { runRequest } from "./trace"
const TuiRequest = z.object({
path: z.string(),
@@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
- await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
+ await runRequest(
+ "TuiRoutes.sessionSelect",
+ c,
+ Session.Service.use((svc) => svc.get(sessionID)),
+ )
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
diff --git a/packages/opencode/src/server/ui/index.ts b/packages/opencode/src/server/routes/ui.ts
index d449cd1c4..d449cd1c4 100644
--- a/packages/opencode/src/server/ui/index.ts
+++ b/packages/opencode/src/server/routes/ui.ts
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 892a99a77..8b1f1aee1 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1,16 +1,21 @@
import { generateSpecs } from "hono-openapi"
import { Hono } from "hono"
import { adapter } from "#hono"
-import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
+import { Log } from "@/util"
+import { Flag } from "@/flag/flag"
+import { WorkspaceID } from "@/control-plane/schema"
+import { MDNS } from "./mdns"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence"
-import { InstanceRoutes } from "./instance"
import { initProjectors } from "./projectors"
-import { Log } from "@/util"
-import { Flag } from "@/flag/flag"
-import { ControlPlaneRoutes } from "./control"
-import { UIRoutes } from "./ui"
+import { InstanceRoutes } from "./routes/instance"
+import { ControlPlaneRoutes } from "./routes/control"
+import { UIRoutes } from "./routes/ui"
+import { GlobalRoutes } from "./routes/global"
+import { WorkspaceRouterMiddleware } from "./workspace"
+import { InstanceMiddleware } from "./routes/instance/middleware"
+import { WorkspaceRoutes } from "./routes/control/workspace"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -30,18 +35,20 @@ export const Default = lazy(() => create({}))
function create(opts: { cors?: string[] }) {
const app = new Hono()
+ .onError(ErrorMiddleware)
+ .use(AuthMiddleware)
+ .use(LoggerMiddleware)
+ .use(CompressionMiddleware)
+ .use(CorsMiddleware(opts))
+ .route("/global", GlobalRoutes())
+
const runtime = adapter.create(app)
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
- .onError(ErrorMiddleware)
- .use(AuthMiddleware)
- .use(LoggerMiddleware)
- .use(CompressionMiddleware)
- .use(CorsMiddleware(opts))
+ .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
.use(FenceMiddleware)
- .route("/", ControlPlaneRoutes())
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
runtime,
}
@@ -49,12 +56,14 @@ function create(opts: { cors?: string[] }) {
return {
app: app
- .onError(ErrorMiddleware)
- .use(AuthMiddleware)
- .use(LoggerMiddleware)
- .use(CompressionMiddleware)
- .use(CorsMiddleware(opts))
.route("/", ControlPlaneRoutes())
+ .route(
+ "/",
+ new Hono()
+ .use(InstanceMiddleware())
+ .route("/experimental/workspace", WorkspaceRoutes())
+ .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
+ )
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/workspace.ts
index 7b66072c2..d30a117d6 100644
--- a/packages/opencode/src/server/instance/middleware.ts
+++ b/packages/opencode/src/server/workspace.ts
@@ -2,17 +2,17 @@ import type { MiddlewareHandler } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Workspace } from "@/control-plane/workspace"
-import { ServerProxy } from "../proxy"
-import { Instance } from "@/project/instance"
-import { InstanceBootstrap } from "@/project/bootstrap"
import { Flag } from "@/flag/flag"
+import { InstanceBootstrap } from "@/project/bootstrap"
+import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
-import { WorkspaceContext } from "@/control-plane/workspace-context"
import { AppRuntime } from "@/effect/app-runtime"
+import { Effect } from "effect"
import { Log } from "@/util"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { ServerProxy } from "./proxy"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
@@ -43,7 +43,9 @@ async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
- const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
+ const session = await AppRuntime.runPromise(
+ Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
+ ).catch(() => undefined)
return session?.workspaceID
}
@@ -51,45 +53,13 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
const log = Log.create({ service: "workspace-router" })
return async (c, next) => {
- const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
- const directory = AppFileSystem.resolve(
- (() => {
- try {
- return decodeURIComponent(raw)
- } catch {
- return raw
- }
- })(),
- )
-
const url = new URL(c.req.url)
const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) {
- if (Flag.OPENCODE_WORKSPACE_ID) {
- return WorkspaceContext.provide({
- workspaceID: WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID),
- async fn() {
- return Instance.provide({
- directory,
- init: () => AppRuntime.runPromise(InstanceBootstrap),
- async fn() {
- return next()
- },
- })
- },
- })
- }
-
- return Instance.provide({
- directory,
- init: () => AppRuntime.runPromise(InstanceBootstrap),
- async fn() {
- return next()
- },
- })
+ return next()
}
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 0060fa6d4..8da684239 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -172,7 +172,7 @@ export const layer: Layer.Layer<
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
- if (cfg.compaction?.prune === false) return
+ if (!cfg.compaction?.prune) return
log.info("pruning")
const msgs = yield* session
diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts
index 768f352d9..122644c1f 100644
--- a/packages/opencode/src/session/instruction.ts
+++ b/packages/opencode/src/session/instruction.ts
@@ -8,7 +8,6 @@ import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
-import { Instance } from "../project/instance"
import { Log } from "../util"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
@@ -82,9 +81,10 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
)
const relative = Effect.fnUntraced(function* (instruction: string) {
+ const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
- .globUp(instruction, Instance.directory, Instance.worktree)
+ .globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
@@ -119,12 +119,13 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
+ const ctx = yield* InstanceState.context
const paths = new Set<string>()
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
- const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
+ const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
@@ -191,9 +192,9 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.S
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
+ const root = path.resolve(yield* InstanceState.directory)
const target = path.resolve(filepath)
- const root = path.resolve(Instance.directory)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index b66e99fc8..b72f873de 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -74,7 +74,7 @@ const live: Layer.Layer<
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
- .tag("sessionID", input.sessionID)
+ .tag("session.id", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
@@ -317,6 +317,18 @@ const live: Layer.Layer<
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
+ const telemetryTracer = tracer
+ ? new Proxy(tracer, {
+ get(target, prop, receiver) {
+ if (prop !== "startSpan") return Reflect.get(target, prop, receiver)
+ return (...args: Parameters<typeof target.startSpan>) => {
+ const span = target.startSpan(...args)
+ span.setAttribute("session.id", input.sessionID)
+ return span
+ }
+ },
+ })
+ : undefined
return streamText({
onError(error) {
@@ -390,7 +402,7 @@ const live: Layer.Layer<
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
- tracer,
+ tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 65f3f8b8d..20528763b 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -11,6 +11,7 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { ProviderError } from "@/provider"
import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
+import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema"
@@ -25,10 +26,7 @@ interface FetchDecompressionError extends Error {
}
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
-
-export function isMedia(mime: string) {
- return mime.startsWith("image/") || mime === "application/pdf"
-}
+export { isMedia }
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 820c61aa9..21f9329c6 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -123,7 +123,7 @@ export const layer: Layer.Layer<
reasoningMap: {},
}
let aborted = false
- const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
+ const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
const parse = (e: unknown) =>
MessageV2.fromError(e, {
@@ -213,7 +213,7 @@ export const layer: Layer.Layer<
return true
})
- const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
+ const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
case "start":
yield* status.set(ctx.sessionID, { type: "busy" })
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 9faa61878..431189d19 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -23,7 +23,6 @@ import MAX_STEPS from "../session/prompt/max-steps.txt"
import { ToolRegistry } from "../tool"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
-import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -94,7 +93,6 @@ export const layer = Layer.effect(
const fsys = yield* AppFileSystem.Service
const mcp = yield* MCP.Service
const lsp = yield* LSP.Service
- const filetime = yield* FileTime.Service
const registry = yield* ToolRegistry.Service
const truncate = yield* Truncate.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
@@ -1183,7 +1181,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
]
}
- yield* filetime.read(input.sessionID, filepath)
return [
{
messageID: info.id,
@@ -1684,7 +1681,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Permission.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(LSP.defaultLayer),
- Layer.provide(FileTime.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Truncate.defaultLayer),
Layer.provide(Provider.defaultLayer),
diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts
index a453b1981..5168b80b5 100644
--- a/packages/opencode/src/session/session.ts
+++ b/packages/opencode/src/session/session.ts
@@ -6,7 +6,6 @@ import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag"
-import { Installation } from "../installation"
import { InstallationVersion } from "../installation/version"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
@@ -650,7 +649,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return input.partID
})
- const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: {
+ const updatePartDelta = Effect.fnUntraced(function* (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
@@ -713,8 +712,10 @@ export function* list(input?: {
if (input?.workspaceID) {
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
}
- if (input?.directory) {
- conditions.push(eq(SessionTable.directory, input.directory))
+ if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
+ if (input?.directory) {
+ conditions.push(eq(SessionTable.directory, input.directory))
+ }
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index 3484d5da7..2622f4f7f 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({
export type Share = typeof ShareSchema.Type
type State = {
- queue: Map<string, { data: Map<string, Data> }>
+ queue: Map<SessionID, Map<string, Data>>
scope: Scope.Closeable
+ shared: Map<SessionID, Share | null>
}
type Data =
@@ -118,17 +119,20 @@ export const layer = Layer.effect(
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
return Effect.gen(function* () {
if (disabled) return
+ const share = yield* getCached(sessionID)
+ if (!share) return
+
const s = yield* InstanceState.get(state)
const existing = s.queue.get(sessionID)
if (existing) {
for (const item of data) {
- existing.data.set(key(item), item)
+ existing.set(key(item), item)
}
return
}
const next = new Map(data.map((item) => [key(item), item]))
- s.queue.set(sessionID, { data: next })
+ s.queue.set(sessionID, next)
yield* flush(sessionID).pipe(
Effect.delay(1000),
Effect.catchCause((cause) =>
@@ -143,13 +147,14 @@ export const layer = Layer.effect(
const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
Effect.fn("ShareNext.state")(function* (_ctx) {
- const cache: State = { queue: new Map(), scope: yield* Scope.make() }
+ const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() }
yield* Effect.addFinalizer(() =>
Scope.close(cache.scope, Exit.void).pipe(
Effect.andThen(
Effect.sync(() => {
cache.queue.clear()
+ cache.shared.clear()
}),
),
),
@@ -227,6 +232,18 @@ export const layer = Layer.effect(
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
})
+ const getCached = Effect.fnUntraced(function* (sessionID: SessionID) {
+ const s = yield* InstanceState.get(state)
+ if (s.shared.has(sessionID)) {
+ const cached = s.shared.get(sessionID)
+ return cached === null ? undefined : cached
+ }
+
+ const share = yield* get(sessionID)
+ s.shared.set(sessionID, share ?? null)
+ return share
+ })
+
const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) {
if (disabled) return
const s = yield* InstanceState.get(state)
@@ -235,13 +252,13 @@ export const layer = Layer.effect(
s.queue.delete(sessionID)
- const share = yield* get(sessionID)
+ const share = yield* getCached(sessionID)
if (!share) return
const req = yield* request()
const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
HttpClientRequest.setHeaders(req.headers),
- HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }),
+ HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }),
Effect.flatMap((r) => http.execute(r)),
)
@@ -307,6 +324,7 @@ export const layer = Layer.effect(
.run(),
)
const s = yield* InstanceState.get(state)
+ s.shared.set(sessionID, result)
yield* full(sessionID).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
@@ -321,8 +339,13 @@ export const layer = Layer.effect(
const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
if (disabled) return
log.info("removing share", { sessionID })
- const share = yield* get(sessionID)
- if (!share) return
+ const s = yield* InstanceState.get(state)
+ const share = yield* getCached(sessionID)
+ if (!share) {
+ s.shared.delete(sessionID)
+ s.queue.delete(sessionID)
+ return
+ }
const req = yield* request()
yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe(
@@ -332,6 +355,8 @@ export const layer = Layer.effect(
)
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
+ s.shared.delete(sessionID)
+ s.queue.delete(sessionID)
})
return Service.of({ init, url, request, create, remove })
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 62b96cba8..f535183d4 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -14,7 +14,6 @@ import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Bus } from "../bus"
import { Format } from "../format"
-import { FileTime } from "../file/time"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
@@ -44,7 +43,6 @@ export const EditTool = Tool.define(
"edit",
Effect.gen(function* () {
const lsp = yield* LSP.Service
- const filetime = yield* FileTime.Service
const afs = yield* AppFileSystem.Service
const format = yield* Format.Service
const bus = yield* Bus.Service
@@ -70,52 +68,11 @@ export const EditTool = Tool.define(
let diff = ""
let contentOld = ""
let contentNew = ""
- yield* filetime.withLock(filePath, () =>
- Effect.gen(function* () {
- if (params.oldString === "") {
- const existed = yield* afs.existsSafe(filePath)
- contentNew = params.newString
- diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
- yield* ctx.ask({
- permission: "edit",
- patterns: [path.relative(Instance.worktree, filePath)],
- always: ["*"],
- metadata: {
- filepath: filePath,
- diff,
- },
- })
- yield* afs.writeWithDirs(filePath, params.newString)
- yield* format.file(filePath)
- yield* bus.publish(File.Event.Edited, { file: filePath })
- yield* bus.publish(FileWatcher.Event.Updated, {
- file: filePath,
- event: existed ? "change" : "add",
- })
- yield* filetime.read(ctx.sessionID, filePath)
- return
- }
-
- const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
- if (!info) throw new Error(`File ${filePath} not found`)
- if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
- yield* filetime.assert(ctx.sessionID, filePath)
- contentOld = yield* afs.readFileString(filePath)
-
- const ending = detectLineEnding(contentOld)
- const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
- const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
-
- contentNew = replace(contentOld, old, next, params.replaceAll)
-
- diff = trimDiff(
- createTwoFilesPatch(
- filePath,
- filePath,
- normalizeLineEndings(contentOld),
- normalizeLineEndings(contentNew),
- ),
- )
+ yield* Effect.gen(function* () {
+ if (params.oldString === "") {
+ const existed = yield* afs.existsSafe(filePath)
+ contentNew = params.newString
+ diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
@@ -125,26 +82,62 @@ export const EditTool = Tool.define(
diff,
},
})
-
- yield* afs.writeWithDirs(filePath, contentNew)
+ yield* afs.writeWithDirs(filePath, params.newString)
yield* format.file(filePath)
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
- event: "change",
+ event: existed ? "change" : "add",
})
- contentNew = yield* afs.readFileString(filePath)
- diff = trimDiff(
- createTwoFilesPatch(
- filePath,
- filePath,
- normalizeLineEndings(contentOld),
- normalizeLineEndings(contentNew),
- ),
- )
- yield* filetime.read(ctx.sessionID, filePath)
- }).pipe(Effect.orDie),
- )
+ return
+ }
+
+ const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+ if (!info) throw new Error(`File ${filePath} not found`)
+ if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
+ contentOld = yield* afs.readFileString(filePath)
+
+ const ending = detectLineEnding(contentOld)
+ const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
+ const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
+
+ contentNew = replace(contentOld, old, next, params.replaceAll)
+
+ diff = trimDiff(
+ createTwoFilesPatch(
+ filePath,
+ filePath,
+ normalizeLineEndings(contentOld),
+ normalizeLineEndings(contentNew),
+ ),
+ )
+ yield* ctx.ask({
+ permission: "edit",
+ patterns: [path.relative(Instance.worktree, filePath)],
+ always: ["*"],
+ metadata: {
+ filepath: filePath,
+ diff,
+ },
+ })
+
+ yield* afs.writeWithDirs(filePath, contentNew)
+ yield* format.file(filePath)
+ yield* bus.publish(File.Event.Edited, { file: filePath })
+ yield* bus.publish(FileWatcher.Event.Updated, {
+ file: filePath,
+ event: "change",
+ })
+ contentNew = yield* afs.readFileString(filePath)
+ diff = trimDiff(
+ createTwoFilesPatch(
+ filePath,
+ filePath,
+ normalizeLineEndings(contentOld),
+ normalizeLineEndings(contentNew),
+ ),
+ )
+ }).pipe(Effect.orDie)
const filediff: Snapshot.FileDiff = {
file: filePath,
diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-exa.ts
index 638d68c24..3340d84ef 100644
--- a/packages/opencode/src/tool/mcp-exa.ts
+++ b/packages/opencode/src/tool/mcp-exa.ts
@@ -1,7 +1,9 @@
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
-const URL = "https://mcp.exa.ai/mcp"
+const URL = process.env.EXA_API_KEY
+ ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}`
+ : "https://mcp.exa.ai/mcp"
const McpResult = Schema.Struct({
result: Schema.Struct({
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index c6d1461cd..c9b304862 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -1,23 +1,23 @@
import z from "zod"
-import { Effect, Scope } from "effect"
+import { Effect, Option, Scope } from "effect"
import { createReadStream } from "fs"
-import { open } from "fs/promises"
import * as path from "path"
import { createInterface } from "readline"
import * as Tool from "./tool"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { LSP } from "../lsp"
-import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction"
+import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
+const SAMPLE_BYTES = 4096
const parameters = z.object({
filePath: z.string().describe("The absolute path to the file or directory to read"),
@@ -31,7 +31,6 @@ export const ReadTool = Tool.define(
const fs = yield* AppFileSystem.Service
const instruction = yield* Instruction.Service
const lsp = yield* LSP.Service
- const time = yield* FileTime.Service
const scope = yield* Scope.Scope
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
@@ -75,11 +74,72 @@ export const ReadTool = Tool.define(
).pipe(Effect.map((items: string[]) => items.sort((a, b) => a.localeCompare(b))))
})
- const warm = Effect.fn("ReadTool.warm")(function* (filepath: string, sessionID: Tool.Context["sessionID"]) {
+ const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
- yield* time.read(sessionID, filepath)
})
+ const readSample = Effect.fn("ReadTool.readSample")(function* (
+ filepath: string,
+ fileSize: number,
+ sampleSize: number,
+ ) {
+ if (fileSize === 0) return new Uint8Array()
+
+ return yield* Effect.scoped(
+ Effect.gen(function* () {
+ const file = yield* fs.open(filepath, { flag: "r" })
+ return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array())
+ }),
+ )
+ })
+
+ const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
+ const ext = path.extname(filepath).toLowerCase()
+ switch (ext) {
+ case ".zip":
+ case ".tar":
+ case ".gz":
+ case ".exe":
+ case ".dll":
+ case ".so":
+ case ".class":
+ case ".jar":
+ case ".war":
+ case ".7z":
+ case ".doc":
+ case ".docx":
+ case ".xls":
+ case ".xlsx":
+ case ".ppt":
+ case ".pptx":
+ case ".odt":
+ case ".ods":
+ case ".odp":
+ case ".bin":
+ case ".dat":
+ case ".obj":
+ case ".o":
+ case ".a":
+ case ".lib":
+ case ".wasm":
+ case ".pyc":
+ case ".pyo":
+ return true
+ }
+
+ if (bytes.length === 0) return false
+
+ let nonPrintableCount = 0
+ for (let i = 0; i < bytes.length; i++) {
+ if (bytes[i] === 0) return true
+ if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
+ nonPrintableCount++
+ }
+ }
+
+ return nonPrintableCount / bytes.length > 0.3
+ }
+
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
if (params.offset !== undefined && params.offset < 1) {
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
@@ -144,12 +204,12 @@ export const ReadTool = Tool.define(
}
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
+ const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
- const mime = AppFileSystem.mimeType(filepath)
- const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
- const isPdf = mime === "application/pdf"
- if (isImage || isPdf) {
- const msg = `${isImage ? "Image" : "PDF"} read successfully`
+ const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
+ if (isImageAttachment(mime) || isPdfAttachment(mime)) {
+ const bytes = yield* fs.readFile(filepath)
+ const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
return {
title,
output: msg,
@@ -162,13 +222,13 @@ export const ReadTool = Tool.define(
{
type: "file" as const,
mime,
- url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
+ url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`,
},
],
}
}
- if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
+ if (isBinaryFile(filepath, sample)) {
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}
@@ -196,7 +256,7 @@ export const ReadTool = Tool.define(
}
output += "\n</content>"
- yield* warm(filepath, ctx.sessionID)
+ yield* warm(filepath)
if (loaded.length > 0) {
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
@@ -264,63 +324,3 @@ async function lines(filepath: string, opts: { limit: number; offset: number })
return { raw, count, cut, more, offset: opts.offset }
}
-
-async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
- const ext = path.extname(filepath).toLowerCase()
- // binary check for common non-text extensions
- switch (ext) {
- case ".zip":
- case ".tar":
- case ".gz":
- case ".exe":
- case ".dll":
- case ".so":
- case ".class":
- case ".jar":
- case ".war":
- case ".7z":
- case ".doc":
- case ".docx":
- case ".xls":
- case ".xlsx":
- case ".ppt":
- case ".pptx":
- case ".odt":
- case ".ods":
- case ".odp":
- case ".bin":
- case ".dat":
- case ".obj":
- case ".o":
- case ".a":
- case ".lib":
- case ".wasm":
- case ".pyc":
- case ".pyo":
- return true
- default:
- break
- }
-
- if (fileSize === 0) return false
-
- const fh = await open(filepath, "r")
- try {
- const sampleSize = Math.min(4096, fileSize)
- const bytes = Buffer.alloc(sampleSize)
- const result = await fh.read(bytes, 0, sampleSize, 0)
- if (result.bytesRead === 0) return false
-
- let nonPrintableCount = 0
- for (let i = 0; i < result.bytesRead; i++) {
- if (bytes[i] === 0) return true
- if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
- nonPrintableCount++
- }
- }
- // If >30% non-printable characters, consider it binary
- return nonPrintableCount / result.bytesRead > 0.3
- } finally {
- await fh.close()
- }
-}
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 34e6b4e36..e27593e59 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -39,7 +39,6 @@ import { InstanceState } from "@/effect"
import { Question } from "../question"
import { Todo } from "../session/todo"
import { LSP } from "../lsp"
-import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Bus } from "../bus"
@@ -80,7 +79,6 @@ export const layer: Layer.Layer<
| Session.Service
| Provider.Service
| LSP.Service
- | FileTime.Service
| Instruction.Service
| AppFileSystem.Service
| Bus.Service
@@ -329,7 +327,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Session.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer),
- Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index 58a66ee74..d86faec2b 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -3,10 +3,10 @@ import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
-import { EffectLogger } from "@/effect"
import { Ripgrep } from "../file/ripgrep"
import { Skill } from "../skill"
import * as Tool from "./tool"
+import DESCRIPTION from "./skill.txt"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
@@ -18,82 +18,59 @@ export const SkillTool = Tool.define(
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
- return () =>
- Effect.gen(function* () {
- const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
+ return {
+ description: DESCRIPTION,
+ parameters: Parameters,
+ execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+ Effect.gen(function* () {
+ const info = yield* skill.get(params.name)
+ if (!info) {
+ const all = yield* skill.all()
+ const available = all.map((item) => item.name).join(", ")
+ throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
+ }
- const description =
- list.length === 0
- ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
- : [
- "Load a specialized skill that provides domain-specific instructions and workflows.",
- "",
- "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
- "",
- "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
- "",
- 'Tool output includes a `<skill_content name="...">` block with the loaded content.',
- "",
- "The following skills provide specialized sets of instructions for particular tasks",
- "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
- "",
- Skill.fmt(list, { verbose: false }),
- ].join("\n")
+ yield* ctx.ask({
+ permission: "skill",
+ patterns: [params.name],
+ always: [params.name],
+ metadata: {},
+ })
- return {
- description,
- parameters: Parameters,
- execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
- Effect.gen(function* () {
- const info = yield* skill.get(params.name)
- if (!info) {
- const all = yield* skill.all()
- const available = all.map((item) => item.name).join(", ")
- throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
- }
+ const dir = path.dirname(info.location)
+ const base = pathToFileURL(dir).href
+ const limit = 10
+ const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
+ Stream.filter((file) => !file.includes("SKILL.md")),
+ Stream.map((file) => path.resolve(dir, file)),
+ Stream.take(limit),
+ Stream.runCollect,
+ Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
+ )
- yield* ctx.ask({
- permission: "skill",
- patterns: [params.name],
- always: [params.name],
- metadata: {},
- })
-
- const dir = path.dirname(info.location)
- const base = pathToFileURL(dir).href
- const limit = 10
- const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
- Stream.filter((file) => !file.includes("SKILL.md")),
- Stream.map((file) => path.resolve(dir, file)),
- Stream.take(limit),
- Stream.runCollect,
- Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
- )
-
- return {
- title: `Loaded skill: ${info.name}`,
- output: [
- `<skill_content name="${info.name}">`,
- `# Skill: ${info.name}`,
- "",
- info.content.trim(),
- "",
- `Base directory for this skill: ${base}`,
- "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
- "Note: file list is sampled.",
- "",
- "<skill_files>",
- files,
- "</skill_files>",
- "</skill_content>",
- ].join("\n"),
- metadata: {
- name: info.name,
- dir,
- },
- }
- }).pipe(Effect.orDie),
- }
- })
+ return {
+ title: `Loaded skill: ${info.name}`,
+ output: [
+ `<skill_content name="${info.name}">`,
+ `# Skill: ${info.name}`,
+ "",
+ info.content.trim(),
+ "",
+ `Base directory for this skill: ${base}`,
+ "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
+ "Note: file list is sampled.",
+ "",
+ "<skill_files>",
+ files,
+ "</skill_files>",
+ "</skill_content>",
+ ].join("\n"),
+ metadata: {
+ name: info.name,
+ dir,
+ },
+ }
+ }).pipe(Effect.orDie),
+ }
}),
)
diff --git a/packages/opencode/src/tool/skill.txt b/packages/opencode/src/tool/skill.txt
new file mode 100644
index 000000000..44d990317
--- /dev/null
+++ b/packages/opencode/src/tool/skill.txt
@@ -0,0 +1,5 @@
+Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
+
+Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
+
+The skill name must match one of the skills listed in your system prompt.
diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts
index 6498b871f..1d988b8d4 100644
--- a/packages/opencode/src/tool/webfetch.ts
+++ b/packages/opencode/src/tool/webfetch.ts
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import * as Tool from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
+import { isImageAttachment } from "@/util/media"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -104,10 +105,7 @@ export const WebFetchTool = Tool.define(
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
const title = `${params.url} (${contentType})`
- // Check if response is an image
- const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
-
- if (isImage) {
+ if (isImageAttachment(mime)) {
const base64Content = Buffer.from(arrayBuffer).toString("base64")
return {
title,
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index c5871eb0e..741091b21 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -9,7 +9,6 @@ import { Bus } from "../bus"
import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Format } from "../format"
-import { FileTime } from "../file/time"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
@@ -22,7 +21,6 @@ export const WriteTool = Tool.define(
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
- const filetime = yield* FileTime.Service
const bus = yield* Bus.Service
const format = yield* Format.Service
@@ -41,7 +39,6 @@ export const WriteTool = Tool.define(
const exists = yield* fs.existsSafe(filepath)
const contentOld = exists ? yield* fs.readFileString(filepath) : ""
- if (exists) yield* filetime.assert(ctx.sessionID, filepath)
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
yield* ctx.ask({
@@ -61,7 +58,6 @@ export const WriteTool = Tool.define(
file: filepath,
event: exists ? "change" : "add",
})
- yield* filetime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
yield* lsp.touchFile(filepath, true)
diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
index 6e99fd468..bf1caa035 100644
--- a/packages/opencode/src/util/effect-zod.ts
+++ b/packages/opencode/src/util/effect-zod.ts
@@ -1,4 +1,4 @@
-import { Schema, SchemaAST } from "effect"
+import { Effect, Option, Schema, SchemaAST } from "effect"
import z from "zod"
/**
@@ -8,19 +8,220 @@ import z from "zod"
*/
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
+/**
+ * Annotation key for a pre-parse transform that runs on the raw input before
+ * the derived Zod schema validates it. The walker emits
+ * `z.preprocess(fn, inner)` when this annotation is present.
+ *
+ * Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema
+ * needs to inspect the user's raw input (e.g. to capture insertion order)
+ * before `Schema.Struct` canonicalises the object.
+ *
+ * TODO: This exists to paper over a missing Effect Schema feature. The
+ * parser canonicalises open struct output (known fields first in
+ * declaration order, then catchall fields) before any user-defined
+ * transform sees the value, and there is no pre-parse hook — so the
+ * user's original property insertion order is gone by the time
+ * `Schema.decodeTo` or `middlewareDecoding` runs.
+ *
+ * That canonicalisation is a reasonable default, but `config/permission.ts`
+ * encodes rule precedence in the user's JSON key order (`evaluate.ts`
+ * uses `findLast`, so later entries win), which the canonicalisation
+ * silently destroys.
+ *
+ * The cleanest upstream fix would be either:
+ *
+ * 1. A `preserveInputOrder` option on `Schema.Struct` /
+ * `Schema.StructWithRest` that keeps the input's insertion order in
+ * the parsed object (opt-in; canonical order stays default).
+ * 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a
+ * transformation whose decode receives the raw `unknown`).
+ *
+ * Either of those would let us delete `ZodPreprocess` and the
+ * `__originalKeys` hack. Alternatively, the permission model could move
+ * to specificity-based precedence (exact keys beat wildcards) or an
+ * explicit ordered array of rules, which removes the ordering
+ * dependency at the data-model level.
+ */
+export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
+
+// AST nodes are immutable and frequently shared across schemas (e.g. a single
+// Schema.Class embedded in multiple parents). Memoizing by node identity
+// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
+// by reference across callers.
+const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
+
+// Shared empty ParseOptions for the rare callers that need one — avoids
+// allocating a fresh object per parse inside refinements and transforms.
+const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
+
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
}
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
+ const cached = walkCache.get(ast)
+ if (cached) return cached
+ const result = walkUncached(ast)
+ walkCache.set(ast, result)
+ return result
+}
+
+function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
if (override) return override
- const out = body(ast)
+ // Schema.Class wraps its fields in a Declaration AST plus an encoding that
+ // constructs the class instance. For the Zod derivation we want the plain
+ // field shape (the decoded/consumer view), not the class instance — so
+ // Declarations fall through to body(), not encoded(). User-level
+ // Schema.decodeTo / Schema.transform attach encoding to non-Declaration
+ // nodes, where we do apply the transform.
+ //
+ // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
+ // on the inner Zod rather than a transform wrapper — so optional ASTs whose
+ // encoding resolves a default from Option.none() route through body()/opt().
+ const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
+ const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
+ const base = hasTransform ? encoded(ast) : body(ast)
+ const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
+ const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
+ const out = preprocess ? z.preprocess(preprocess, checked) : checked
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
- const next = desc ? out.describe(desc) : out
- return ref ? next.meta({ ref }) : next
+ const described = desc ? out.describe(desc) : out
+ return ref ? described.meta({ ref }) : described
+}
+
+// Walk the encoded side and apply each link's decode to produce the decoded
+// shape. A node `Target` produced by `from.decodeTo(Target)` carries
+// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
+// nest the encoding via `Link.to` so walking it recursively threads all
+// prior transforms — typical encoding.length is 1.
+function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
+ const encoding = ast.encoding!
+ return encoding.reduce<z.ZodTypeAny>(
+ (acc, link) => acc.transform((v) => decode(link.transformation, v)),
+ walk(encoding[0].to),
+ )
+}
+
+// Transformations built via pure `SchemaGetter.transform(fn)` (the common
+// decodeTo case) resolve synchronously, so running with no services is safe.
+// Effectful / middleware-based transforms will surface as Effect defects.
+function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
+ const exit = Effect.runSyncExit(
+ (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
+ Option.Option<unknown>
+ >,
+ )
+ if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
+ return Option.getOrElse(exit.value, () => value)
+}
+
+// Flatten FilterGroups and any nested variants into a linear list of Filters.
+// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
+// translated into native Zod methods so their JSON Schema output includes
+// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
+// Anything else falls back to a single .superRefine layer — runtime-only,
+// emits no JSON Schema constraint.
+function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
+ const filters: SchemaAST.Filter<unknown>[] = []
+ const collect = (c: SchemaAST.Check<unknown>) => {
+ if (c._tag === "FilterGroup") c.checks.forEach(collect)
+ else filters.push(c)
+ }
+ checks.forEach(collect)
+
+ const unhandled: SchemaAST.Filter<unknown>[] = []
+ const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
+ const next = translateFilter(acc, filter)
+ if (next) return next
+ unhandled.push(filter)
+ return acc
+ }, out)
+
+ if (unhandled.length === 0) return translated
+
+ return translated.superRefine((value, ctx) => {
+ for (const filter of unhandled) {
+ const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
+ if (!issue) continue
+ const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
+ ctx.addIssue({ code: "custom", message })
+ }
+ })
+}
+
+// Translate a well-known Effect Schema filter into a native Zod method call on
+// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
+// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
+// construction time. Returns `undefined` for unrecognised filters so the
+// caller can fall back to the generic .superRefine path.
+function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
+ const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
+ if (!meta || typeof meta._tag !== "string") return undefined
+ switch (meta._tag) {
+ case "isInt":
+ return call(out, "int")
+ case "isFinite":
+ return call(out, "finite")
+ case "isGreaterThan":
+ return call(out, "gt", meta.exclusiveMinimum)
+ case "isGreaterThanOrEqualTo":
+ return call(out, "gte", meta.minimum)
+ case "isLessThan":
+ return call(out, "lt", meta.exclusiveMaximum)
+ case "isLessThanOrEqualTo":
+ return call(out, "lte", meta.maximum)
+ case "isBetween": {
+ const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
+ if (!lo) return undefined
+ return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
+ }
+ case "isMultipleOf":
+ return call(out, "multipleOf", meta.divisor)
+ case "isMinLength":
+ return call(out, "min", meta.minLength)
+ case "isMaxLength":
+ return call(out, "max", meta.maxLength)
+ case "isLengthBetween": {
+ const lo = call(out, "min", meta.minimum)
+ if (!lo) return undefined
+ return call(lo, "max", meta.maximum)
+ }
+ case "isPattern":
+ return call(out, "regex", meta.regExp)
+ case "isStartsWith":
+ return call(out, "startsWith", meta.startsWith)
+ case "isEndsWith":
+ return call(out, "endsWith", meta.endsWith)
+ case "isIncludes":
+ return call(out, "includes", meta.includes)
+ case "isUUID":
+ return call(out, "uuid")
+ case "isULID":
+ return call(out, "ulid")
+ case "isBase64":
+ return call(out, "base64")
+ case "isBase64Url":
+ return call(out, "base64url")
+ }
+ return undefined
+}
+
+// Invoke a named Zod method on `target` if it exists, otherwise return
+// undefined so the caller can fall back. Using this helper instead of a
+// typed cast keeps `translateFilter` free of per-case narrowing noise.
+function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
+ const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
+ return typeof fn === "function" ? fn.apply(target, args) : undefined
+}
+
+function issueMessage(issue: any): string | undefined {
+ if (typeof issue?.annotations?.message === "string") return issue.annotations.message
+ if (typeof issue?.message === "string") return issue.message
+ return undefined
}
function body(ast: SchemaAST.AST): z.ZodTypeAny {
@@ -60,10 +261,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
if (ast._tag !== "Union") return fail(ast)
const items = ast.types.filter((item) => item._tag !== "Undefined")
- if (items.length === 1) return walk(items[0]).optional()
- if (items.length > 1)
- return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
- return z.undefined().optional()
+ const inner =
+ items.length === 1
+ ? walk(items[0])
+ : items.length > 1
+ ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
+ : z.undefined()
+ // Schema.withDecodingDefault attaches an encoding `Link` whose transformation
+ // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
+ // it to extract the default and emit `.default(...)` instead of `.optional()`.
+ const fallback = extractDefault(ast)
+ if (fallback !== undefined) return inner.default(fallback.value)
+ return inner.optional()
+}
+
+type DecodeLink = {
+ readonly transformation: {
+ readonly decode: {
+ readonly run: (
+ input: Option.Option<unknown>,
+ options: SchemaAST.ParseOptions,
+ ) => Effect.Effect<Option.Option<unknown>, unknown>
+ }
+ }
+}
+
+function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
+ const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
+ if (!encoding?.length) return undefined
+ // Walk the chain of encoding Links in order; the first Getter that produces
+ // a value from Option.none wins. withDecodingDefault always puts its
+ // defaulting Link adjacent to the optional Union.
+ for (const link of encoding) {
+ const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
+ if (probe._tag !== "Success") continue
+ if (Option.isSome(probe.value)) return { value: probe.value.value }
+ }
+ return undefined
}
function union(ast: SchemaAST.Union): z.ZodTypeAny {
@@ -86,21 +320,40 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
}
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
+ // Pure record: { [k: string]: V }
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
return z.record(z.string(), walk(sig.type))
}
- if (ast.indexSignatures.length > 0) return fail(ast)
+ // Pure object with known fields and no index signatures.
+ if (ast.indexSignatures.length === 0) {
+ return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
+ }
- return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
+ // Struct with a catchall (StructWithRest): known fields + index signature.
+ // Only supports a single string-keyed index signature; multi-signature or
+ // symbol/number keys fall through to fail.
+ if (ast.indexSignatures.length !== 1) return fail(ast)
+ const sig = ast.indexSignatures[0]
+ if (sig.parameter._tag !== "String") return fail(ast)
+ return z
+ .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
+ .catchall(walk(sig.type))
}
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
- if (ast.elements.length > 0) return fail(ast)
- if (ast.rest.length !== 1) return fail(ast)
- return z.array(walk(ast.rest[0]))
+ // Pure variadic arrays: { elements: [], rest: [item] }
+ if (ast.elements.length === 0) {
+ if (ast.rest.length !== 1) return fail(ast)
+ return z.array(walk(ast.rest[0]))
+ }
+ // Fixed-length tuples: { elements: [a, b, ...], rest: [] }
+ // Tuples with a variadic tail (...rest) are not yet supported.
+ if (ast.rest.length > 0) return fail(ast)
+ const items = ast.elements.map(walk)
+ return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
}
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts
new file mode 100644
index 000000000..566ac843a
--- /dev/null
+++ b/packages/opencode/src/util/media.ts
@@ -0,0 +1,26 @@
+const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
+
+export function isPdfAttachment(mime: string) {
+ return mime === "application/pdf"
+}
+
+export function isMedia(mime: string) {
+ return mime.startsWith("image/") || isPdfAttachment(mime)
+}
+
+export function isImageAttachment(mime: string) {
+ return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
+}
+
+export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) {
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
+ if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
+ if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
+ if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
+ if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) {
+ return "image/webp"
+ }
+
+ return fallback
+}
diff --git a/packages/opencode/src/util/opencode-process.ts b/packages/opencode/src/util/opencode-process.ts
new file mode 100644
index 000000000..f59270ad2
--- /dev/null
+++ b/packages/opencode/src/util/opencode-process.ts
@@ -0,0 +1,24 @@
+export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
+export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
+
+export function ensureRunID() {
+ return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
+}
+
+export function ensureProcessRole(fallback: "main" | "worker") {
+ return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
+}
+
+export function ensureProcessMetadata(fallback: "main" | "worker") {
+ return {
+ runID: ensureRunID(),
+ processRole: ensureProcessRole(fallback),
+ }
+}
+
+export function sanitizedProcessEnv(overrides?: Record<string, string>) {
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
+ )
+ return overrides ? Object.assign(env, overrides) : env
+}
diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts
new file mode 100644
index 000000000..3fe4266c0
--- /dev/null
+++ b/packages/opencode/src/v2/session-entry-stepper.ts
@@ -0,0 +1,261 @@
+import { produce, type WritableDraft } from "immer"
+import { SessionEvent } from "./session-event"
+import { SessionEntry } from "./session-entry"
+
+export type MemoryState = {
+ entries: SessionEntry.Entry[]
+ pending: SessionEntry.Entry[]
+}
+
+export interface Adapter<Result> {
+ readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined
+ readonly updateAssistant: (assistant: SessionEntry.Assistant) => void
+ readonly appendEntry: (entry: SessionEntry.Entry) => void
+ readonly appendPending: (entry: SessionEntry.Entry) => void
+ readonly finish: () => Result
+}
+
+export function memory(state: MemoryState): Adapter<MemoryState> {
+ const activeAssistantIndex = () =>
+ state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
+
+ return {
+ getCurrentAssistant() {
+ const index = activeAssistantIndex()
+ if (index < 0) return
+ const assistant = state.entries[index]
+ return assistant?.type === "assistant" ? assistant : undefined
+ },
+ updateAssistant(assistant) {
+ const index = activeAssistantIndex()
+ if (index < 0) return
+ const current = state.entries[index]
+ if (current?.type !== "assistant") return
+ state.entries[index] = assistant
+ },
+ appendEntry(entry) {
+ state.entries.push(entry)
+ },
+ appendPending(entry) {
+ state.pending.push(entry)
+ },
+ finish() {
+ return state
+ },
+ }
+}
+
+export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
+ const currentAssistant = adapter.getCurrentAssistant()
+ type DraftAssistant = WritableDraft<SessionEntry.Assistant>
+ type DraftTool = WritableDraft<SessionEntry.AssistantTool>
+ type DraftText = WritableDraft<SessionEntry.AssistantText>
+ type DraftReasoning = WritableDraft<SessionEntry.AssistantReasoning>
+
+ const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
+ assistant?.content.findLast(
+ (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
+ )
+
+ const latestText = (assistant: DraftAssistant | undefined) =>
+ assistant?.content.findLast((item): item is DraftText => item.type === "text")
+
+ const latestReasoning = (assistant: DraftAssistant | undefined) =>
+ assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning")
+
+ SessionEvent.Event.match(event, {
+ prompt: (event) => {
+ const entry = SessionEntry.User.fromEvent(event)
+ if (currentAssistant) {
+ adapter.appendPending(entry)
+ return
+ }
+ adapter.appendEntry(entry)
+ },
+ synthetic: (event) => {
+ adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event))
+ },
+ "step.started": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.time.completed = event.timestamp
+ }),
+ )
+ }
+ adapter.appendEntry(SessionEntry.Assistant.fromEvent(event))
+ },
+ "step.ended": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.time.completed = event.timestamp
+ draft.cost = event.cost
+ draft.tokens = event.tokens
+ }),
+ )
+ }
+ },
+ "text.started": () => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.content.push({
+ type: "text",
+ text: "",
+ })
+ }),
+ )
+ }
+ },
+ "text.delta": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestText(draft)
+ if (match) match.text += event.delta
+ }),
+ )
+ }
+ },
+ "text.ended": () => {},
+ "tool.input.started": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.content.push({
+ type: "tool",
+ callID: event.callID,
+ name: event.name,
+ time: {
+ created: event.timestamp,
+ },
+ state: {
+ status: "pending",
+ input: "",
+ },
+ })
+ }),
+ )
+ }
+ },
+ "tool.input.delta": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestTool(draft, event.callID)
+ // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
+ if (match && match.state.status === "pending") match.state.input += event.delta
+ }),
+ )
+ }
+ },
+ "tool.input.ended": () => {},
+ "tool.called": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestTool(draft, event.callID)
+ if (match) {
+ match.time.ran = event.timestamp
+ match.state = {
+ status: "running",
+ input: event.input,
+ }
+ }
+ }),
+ )
+ }
+ },
+ "tool.success": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestTool(draft, event.callID)
+ if (match && match.state.status === "running") {
+ match.state = {
+ status: "completed",
+ input: match.state.input,
+ output: event.output ?? "",
+ title: event.title,
+ metadata: event.metadata ?? {},
+ attachments: [...(event.attachments ?? [])],
+ }
+ }
+ }),
+ )
+ }
+ },
+ "tool.error": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestTool(draft, event.callID)
+ if (match && match.state.status === "running") {
+ match.state = {
+ status: "error",
+ error: event.error,
+ input: match.state.input,
+ metadata: event.metadata ?? {},
+ }
+ }
+ }),
+ )
+ }
+ },
+ "reasoning.started": () => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.content.push({
+ type: "reasoning",
+ text: "",
+ })
+ }),
+ )
+ }
+ },
+ "reasoning.delta": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestReasoning(draft)
+ if (match) match.text += event.delta
+ }),
+ )
+ }
+ },
+ "reasoning.ended": (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ const match = latestReasoning(draft)
+ if (match) match.text = event.text
+ }),
+ )
+ }
+ },
+ retried: (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
+ }),
+ )
+ }
+ },
+ compacted: (event) => {
+ adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
+ },
+ })
+
+ return adapter.finish()
+}
+
+export function step(old: MemoryState, event: SessionEvent.Event): MemoryState {
+ return produce(old, (draft) => {
+ stepWith(memory(draft as MemoryState), event)
+ })
+}
+
+export * as SessionEntryStepper from "./session-entry-stepper"
diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts
index 140fa47d2..b261d8b5b 100644
--- a/packages/opencode/src/v2/session-entry.ts
+++ b/packages/opencode/src/v2/session-entry.ts
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { SessionEvent } from "./session-event"
-import { produce } from "immer"
export const ID = SessionEvent.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -40,7 +39,14 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic"
...SessionEvent.Synthetic.fields,
...Base,
type: Schema.Literal("synthetic"),
-}) {}
+}) {
+ static fromEvent(event: SessionEvent.Synthetic) {
+ return new Synthetic({
+ ...event,
+ time: { created: event.timestamp },
+ })
+ }
+}
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
status: Schema.Literal("pending"),
@@ -70,7 +76,9 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
-export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
+export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
+ Schema.toTaggedUnion("status"),
+)
export type ToolState = Schema.Schema.Type<typeof ToolState>
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
@@ -96,13 +104,34 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
text: Schema.String,
}) {}
-export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
+export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
+ attempt: Schema.Number,
+ error: SessionEvent.RetryError,
+ time: Schema.Struct({
+ created: Schema.DateTimeUtc,
+ }),
+}) {
+ static fromEvent(event: SessionEvent.Retried) {
+ return new AssistantRetry({
+ attempt: event.attempt,
+ error: event.error,
+ time: {
+ created: event.timestamp,
+ },
+ })
+ }
+}
+
+export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+ Schema.toTaggedUnion("type"),
+)
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
...Base,
type: Schema.Literal("assistant"),
content: AssistantContent.pipe(Schema.Array),
+ retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
cost: Schema.Number.pipe(Schema.optional),
tokens: Schema.Struct({
input: Schema.Number,
@@ -118,168 +147,40 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
created: Schema.DateTimeUtc,
completed: Schema.DateTimeUtc.pipe(Schema.optional),
}),
-}) {}
+}) {
+ static fromEvent(event: SessionEvent.Step.Started) {
+ return new Assistant({
+ id: event.id,
+ type: "assistant",
+ time: {
+ created: event.timestamp,
+ },
+ content: [],
+ retries: [],
+ })
+ }
+}
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
...SessionEvent.Compacted.fields,
type: Schema.Literal("compaction"),
...Base,
-}) {}
+}) {
+ static fromEvent(event: SessionEvent.Compacted) {
+ return new Compaction({
+ ...event,
+ type: "compaction",
+ time: { created: event.timestamp },
+ })
+ }
+}
-export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
+export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
export type Entry = Schema.Schema.Type<typeof Entry>
export type Type = Entry["type"]
-export type History = {
- entries: Entry[]
- pending: Entry[]
-}
-
-export function step(old: History, event: SessionEvent.Event): History {
- return produce(old, (draft) => {
- const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
- const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
-
- switch (event.type) {
- case "prompt": {
- if (pendingAssistant) {
- // @ts-expect-error
- draft.pending.push(User.fromEvent(event))
- break
- }
- // @ts-expect-error
- draft.entries.push(User.fromEvent(event))
- break
- }
- case "step.started": {
- if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
- draft.entries.push({
- id: event.id,
- type: "assistant",
- time: {
- created: event.timestamp,
- },
- content: [],
- })
- break
- }
- case "text.started": {
- if (!pendingAssistant) break
- pendingAssistant.content.push({
- type: "text",
- text: "",
- })
- break
- }
- case "text.delta": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "text")
- if (match) match.text += event.delta
- break
- }
- case "text.ended": {
- break
- }
- case "tool.input.started": {
- if (!pendingAssistant) break
- pendingAssistant.content.push({
- type: "tool",
- callID: event.callID,
- name: event.name,
- time: {
- created: event.timestamp,
- },
- state: {
- status: "pending",
- input: "",
- },
- })
- break
- }
- case "tool.input.delta": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "tool")
- // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
- if (match) match.state.input += event.delta
- break
- }
- case "tool.input.ended": {
- break
- }
- case "tool.called": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "tool")
- if (match) {
- match.time.ran = event.timestamp
- match.state = {
- status: "running",
- input: event.input,
- }
- }
- break
- }
- case "tool.success": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "tool")
- if (match && match.state.status === "running") {
- match.state = {
- status: "completed",
- input: match.state.input,
- output: event.output ?? "",
- title: event.title,
- metadata: event.metadata ?? {},
- // @ts-expect-error
- attachments: event.attachments ?? [],
- }
- }
- break
- }
- case "tool.error": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "tool")
- if (match && match.state.status === "running") {
- match.state = {
- status: "error",
- error: event.error,
- input: match.state.input,
- metadata: event.metadata ?? {},
- }
- }
- break
- }
- case "reasoning.started": {
- if (!pendingAssistant) break
- pendingAssistant.content.push({
- type: "reasoning",
- text: "",
- })
- break
- }
- case "reasoning.delta": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
- if (match) match.text += event.delta
- break
- }
- case "reasoning.ended": {
- if (!pendingAssistant) break
- const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
- if (match) match.text = event.text
- break
- }
- case "step.ended": {
- if (!pendingAssistant) break
- pendingAssistant.time.completed = event.timestamp
- pendingAssistant.cost = event.cost
- pendingAssistant.tokens = event.tokens
- break
- }
- }
- })
-}
-
/*
export interface Interface {
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts
index 8ea239033..f922becf3 100644
--- a/packages/opencode/src/v2/session-event.ts
+++ b/packages/opencode/src/v2/session-event.ts
@@ -53,6 +53,15 @@ export namespace SessionEvent {
source: Source.pipe(Schema.optional),
}) {}
+ export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
+ message: Schema.String,
+ statusCode: Schema.Number.pipe(Schema.optional),
+ isRetryable: Schema.Boolean,
+ responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+ responseBody: Schema.String.pipe(Schema.optional),
+ metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+ }) {}
+
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
...Base,
type: Schema.Literal("prompt"),
@@ -386,14 +395,16 @@ export namespace SessionEvent {
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
...Base,
type: Schema.Literal("retried"),
- error: Schema.String,
+ attempt: Schema.Number,
+ error: RetryError,
}) {
- static create(input: BaseInput & { error: string }) {
+ static create(input: BaseInput & { attempt: number; error: RetryError }) {
return new Retried({
id: input.id ?? ID.create(),
type: "retried",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
+ attempt: input.attempt,
error: input.error,
})
}
@@ -441,7 +452,7 @@ export namespace SessionEvent {
{
mode: "oneOf",
},
- )
+ ).pipe(Schema.toTaggedUnion("type"))
export type Event = Schema.Schema.Type<typeof Event>
export type Type = Event["type"]
}
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index aa1dc2f8f..bbebeaa49 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -365,13 +365,14 @@ export const layer: Layer.Layer<
}
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
- if (Instance.project.vcs !== "git") {
+ const ctx = yield* InstanceState.context
+ if (ctx.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = yield* canonical(input.directory)
- const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+ const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (list.code !== 0) {
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
@@ -389,9 +390,9 @@ export const layer: Layer.Layer<
}
yield* stopFsmonitor(entry.path)
- const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
+ const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree })
if (removed.code !== 0) {
- const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+ const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (next.code !== 0) {
throw new RemoveFailedError({
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
@@ -408,7 +409,7 @@ export const layer: Layer.Layer<
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
- const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
+ const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree })
if (deleted.code !== 0) {
throw new RemoveFailedError({
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
@@ -498,17 +499,18 @@ export const layer: Layer.Layer<
})
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
- if (Instance.project.vcs !== "git") {
+ const ctx = yield* InstanceState.context
+ if (ctx.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = yield* canonical(input.directory)
- const primary = yield* canonical(Instance.worktree)
+ const primary = yield* canonical(ctx.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
- const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+ const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
if (list.code !== 0) {
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
@@ -520,7 +522,7 @@ export const layer: Layer.Layer<
const worktreePath = entry.path
- const base = yield* gitSvc.defaultBranch(Instance.worktree)
+ const base = yield* gitSvc.defaultBranch(ctx.worktree)
if (!base) {
throw new ResetFailedError({ message: "Default branch not found" })
}
@@ -531,7 +533,7 @@ export const layer: Layer.Layer<
const branch = base.ref.slice(sep + 1)
yield* gitExpect(
["fetch", remote, branch],
- { cwd: Instance.worktree },
+ { cwd: ctx.worktree },
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
)
}
@@ -574,7 +576,7 @@ export const layer: Layer.Layer<
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
}
- yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
+ yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
Effect.forkIn(scope),
)
diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
index 395e8ce42..74236afae 100644
--- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
+++ b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts
@@ -56,7 +56,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -117,7 +117,7 @@ test("does not use npm package exports dot for tui entry", async () => {
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -179,7 +179,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -241,7 +241,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
@@ -299,7 +299,7 @@ test("does not use npm package main for tui entry", async () => {
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const warn = spyOn(console, "warn").mockImplementation(() => {})
const error = spyOn(console, "error").mockImplementation(() => {})
@@ -468,7 +468,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx
deleted file mode 100644
index e75e18619..000000000
--- a/packages/opencode/test/cli/tui/sync-provider.test.tsx
+++ /dev/null
@@ -1,280 +0,0 @@
-/** @jsxImportSource @opentui/solid */
-import { afterEach, describe, expect, test } from "bun:test"
-import { testRender } from "@opentui/solid"
-import { onMount } from "solid-js"
-import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
-import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
-import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
-import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
-import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
-
-const sighup = new Set(process.listeners("SIGHUP"))
-
-afterEach(() => {
- for (const fn of process.listeners("SIGHUP")) {
- if (!sighup.has(fn)) process.off("SIGHUP", fn)
- }
-})
-
-function json(data: unknown) {
- return new Response(JSON.stringify(data), {
- headers: {
- "content-type": "application/json",
- },
- })
-}
-
-async function wait(fn: () => boolean, timeout = 2000) {
- const start = Date.now()
- while (!fn()) {
- if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
- await Bun.sleep(10)
- }
-}
-
-function data(workspace?: string | null) {
- const tag = workspace ?? "root"
- return {
- session: {
- id: "ses_1",
- title: `session-${tag}`,
- workspaceID: workspace ?? undefined,
- time: {
- updated: 1,
- },
- },
- message: {
- info: {
- id: "msg_1",
- sessionID: "ses_1",
- role: "assistant",
- time: {
- created: 1,
- completed: 1,
- },
- },
- parts: [
- {
- id: "part_1",
- messageID: "msg_1",
- sessionID: "ses_1",
- type: "text",
- text: `part-${tag}`,
- },
- ],
- },
- todo: [
- {
- id: `todo-${tag}`,
- content: `todo-${tag}`,
- status: "pending",
- priority: "medium",
- },
- ],
- diff: [
- {
- file: `${tag}.ts`,
- patch: "",
- additions: 0,
- deletions: 0,
- },
- ],
- }
-}
-
-type Hit = {
- path: string
- workspace?: string
-}
-
-function createFetch(log: Hit[]) {
- return Object.assign(
- async (input: RequestInfo | URL, init?: RequestInit) => {
- const req = new Request(input, init)
- const url = new URL(req.url)
- const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
- log.push({
- path: url.pathname,
- workspace,
- })
-
- if (url.pathname === "/config/providers") {
- return json({ providers: [], default: {} })
- }
- if (url.pathname === "/provider") {
- return json({ all: [], default: {}, connected: [] })
- }
- if (url.pathname === "/experimental/console") {
- return json({})
- }
- if (url.pathname === "/agent") {
- return json([])
- }
- if (url.pathname === "/config") {
- return json({})
- }
- if (url.pathname === "/project/current") {
- return json({ id: `proj-${workspace ?? "root"}` })
- }
- if (url.pathname === "/path") {
- return json({
- state: `/tmp/${workspace ?? "root"}/state`,
- config: `/tmp/${workspace ?? "root"}/config`,
- worktree: "/tmp/worktree",
- directory: `/tmp/${workspace ?? "root"}`,
- })
- }
- if (url.pathname === "/session") {
- return json([])
- }
- if (url.pathname === "/command") {
- return json([])
- }
- if (url.pathname === "/lsp") {
- return json([])
- }
- if (url.pathname === "/mcp") {
- return json({})
- }
- if (url.pathname === "/experimental/resource") {
- return json({})
- }
- if (url.pathname === "/formatter") {
- return json([])
- }
- if (url.pathname === "/session/status") {
- return json({})
- }
- if (url.pathname === "/provider/auth") {
- return json({})
- }
- if (url.pathname === "/vcs") {
- return json({ branch: "main" })
- }
- if (url.pathname === "/experimental/workspace") {
- return json([{ id: "ws_a" }, { id: "ws_b" }])
- }
- if (url.pathname === "/session/ses_1") {
- return json(data(workspace).session)
- }
- if (url.pathname === "/session/ses_1/message") {
- return json([data(workspace).message])
- }
- if (url.pathname === "/session/ses_1/todo") {
- return json(data(workspace).todo)
- }
- if (url.pathname === "/session/ses_1/diff") {
- return json(data(workspace).diff)
- }
-
- throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
- },
- { preconnect: fetch.preconnect.bind(fetch) },
- ) satisfies typeof fetch
-}
-
-async function mount(log: Hit[]) {
- let project!: ReturnType<typeof useProject>
- let sync!: ReturnType<typeof useSync>
- let done!: () => void
- const ready = new Promise<void>((resolve) => {
- done = resolve
- })
-
- const app = await testRender(() => (
- <SDKProvider
- url="http://test"
- directory="/tmp/root"
- fetch={createFetch(log)}
- events={{ subscribe: async () => () => {} }}
- >
- <ArgsProvider continue={false}>
- <ExitProvider>
- <ProjectProvider>
- <SyncProvider>
- <Probe
- onReady={(ctx) => {
- project = ctx.project
- sync = ctx.sync
- done()
- }}
- />
- </SyncProvider>
- </ProjectProvider>
- </ExitProvider>
- </ArgsProvider>
- </SDKProvider>
- ))
-
- await ready
- return { app, project, sync }
-}
-
-async function waitBoot(log: Hit[], workspace?: string) {
- await wait(() => log.some((item) => item.path === "/experimental/workspace"))
- if (!workspace) return
- await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
-}
-
-function Probe(props: {
- onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
-}) {
- const project = useProject()
- const sync = useSync()
-
- onMount(() => {
- props.onReady({ project, sync })
- })
-
- return <box />
-}
-
-describe("SyncProvider", () => {
- test("re-runs bootstrap requests when the active workspace changes", async () => {
- const log: Hit[] = []
- const { app, project } = await mount(log)
-
- try {
- await waitBoot(log)
- log.length = 0
-
- project.workspace.set("ws_a")
-
- await waitBoot(log, "ws_a")
-
- expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
- expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
- expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
- } finally {
- app.renderer.destroy()
- }
- })
-
- test("clears full-sync cache when the active workspace changes", async () => {
- const log: Hit[] = []
- const { app, project, sync } = await mount(log)
-
- try {
- await waitBoot(log)
-
- log.length = 0
- project.workspace.set("ws_a")
- await waitBoot(log, "ws_a")
- expect(project.workspace.current()).toBe("ws_a")
-
- log.length = 0
- await sync.session.sync("ses_1")
- expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
-
- project.workspace.set("ws_b")
- await waitBoot(log, "ws_b")
- expect(project.workspace.current()).toBe("ws_b")
-
- log.length = 0
- await sync.session.sync("ses_1")
- expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
- } finally {
- app.renderer.destroy()
- }
- })
-})
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index a321b558c..9f2bf9db9 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -27,7 +27,7 @@ import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util"
import { ConfigPlugin } from "@/config/plugin"
-import { Npm } from "@opencode-ai/shared/npm"
+import { Npm } from "@/npm"
const emptyAccount = Layer.mock(Account.Service)({
active: () => Effect.succeed(Option.none()),
diff --git a/packages/opencode/test/config/lsp.test.ts b/packages/opencode/test/config/lsp.test.ts
new file mode 100644
index 000000000..1d24fe124
--- /dev/null
+++ b/packages/opencode/test/config/lsp.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, test } from "bun:test"
+import { Schema } from "effect"
+import { ConfigLSP } from "../../src/config/lsp"
+
+// The LSP config refinement enforces: any custom (non-builtin) LSP server
+// entry must declare an `extensions` array so the client knows which files
+// the server should attach to. Builtin server IDs and explicitly disabled
+// entries are exempt.
+//
+// Both validation paths must honor this rule:
+// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer)
+// - `ConfigLSP.Info.zod.parse(...)` (derived Zod)
+//
+// `typescript` is a builtin server id (see src/lsp/server.ts).
+describe("ConfigLSP.Info refinement", () => {
+ const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
+
+ describe("accepted inputs", () => {
+ test("true and false pass (top-level toggle)", () => {
+ expect(decodeEffect(true)).toBe(true)
+ expect(decodeEffect(false)).toBe(false)
+ expect(ConfigLSP.Info.zod.parse(true)).toBe(true)
+ expect(ConfigLSP.Info.zod.parse(false)).toBe(false)
+ })
+
+ test("builtin server with no extensions passes", () => {
+ const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
+ expect(decodeEffect(input)).toEqual(input)
+ expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+ })
+
+ test("custom server WITH extensions passes", () => {
+ const input = {
+ "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
+ }
+ expect(decodeEffect(input)).toEqual(input)
+ expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+ })
+
+ test("disabled custom server passes (no extensions needed)", () => {
+ const input = { "my-lsp": { disabled: true as const } }
+ expect(decodeEffect(input)).toEqual(input)
+ expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+ })
+
+ test("mix of builtin and custom with extensions passes", () => {
+ const input = {
+ typescript: { command: ["typescript-language-server", "--stdio"] },
+ "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
+ }
+ expect(decodeEffect(input)).toEqual(input)
+ expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+ })
+ })
+
+ describe("rejected inputs", () => {
+ const expectedMessage = "For custom LSP servers, 'extensions' array is required."
+
+ test("custom server WITHOUT extensions fails via Effect decode", () => {
+ expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
+ })
+
+ test("custom server WITHOUT extensions fails via derived Zod", () => {
+ const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } })
+ expect(result.success).toBe(false)
+ expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true)
+ })
+
+ test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
+ // Boolean(['']) is true, so a non-empty array of strings is fine.
+ // Boolean([]) is also true in JS, so empty arrays are accepted by the
+ // refinement. This test documents current behavior.
+ const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
+ expect(decodeEffect(input)).toEqual(input)
+ expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
+ })
+
+ test("custom server without extensions mixed with a valid builtin still fails", () => {
+ const input = {
+ typescript: { command: ["typescript-language-server", "--stdio"] },
+ "my-lsp": { command: ["my-lsp-bin"] },
+ }
+ expect(() => decodeEffect(input)).toThrow(expectedMessage)
+ expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false)
+ })
+ })
+})
diff --git a/packages/opencode/test/effect/observability.test.ts b/packages/opencode/test/effect/observability.test.ts
new file mode 100644
index 000000000..d06220282
--- /dev/null
+++ b/packages/opencode/test/effect/observability.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { resource } from "../../src/effect/observability"
+
+const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
+const opencodeClient = process.env.OPENCODE_CLIENT
+
+afterEach(() => {
+ if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
+ else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
+
+ if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
+ else process.env.OPENCODE_CLIENT = opencodeClient
+})
+
+describe("resource", () => {
+ test("parses and decodes OTEL resource attributes", () => {
+ process.env.OTEL_RESOURCE_ATTRIBUTES =
+ "service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
+
+ expect(resource().attributes).toMatchObject({
+ "service.namespace": "anomalyco",
+ team: "platform,observability",
+ label: "hello=world",
+ "key/name": "value here",
+ })
+ })
+
+ test("drops OTEL resource attributes when any entry is invalid", () => {
+ process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
+
+ expect(resource().attributes["service.namespace"]).toBeUndefined()
+ expect(resource().attributes["opencode.client"]).toBeDefined()
+ })
+
+ test("keeps built-in attributes when env values conflict", () => {
+ process.env.OPENCODE_CLIENT = "cli"
+ process.env.OTEL_RESOURCE_ATTRIBUTES =
+ "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
+
+ expect(resource().attributes).toMatchObject({
+ "opencode.client": "cli",
+ "service.namespace": "anomalyco",
+ })
+ expect(resource().attributes["service.instance.id"]).not.toBe("override")
+ })
+})
diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts
deleted file mode 100644
index cb6390df8..000000000
--- a/packages/opencode/test/file/time.test.ts
+++ /dev/null
@@ -1,422 +0,0 @@
-import { afterEach, describe, expect } from "bun:test"
-import fs from "fs/promises"
-import path from "path"
-import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
-import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { FileTime } from "../../src/file/time"
-import { Instance } from "../../src/project/instance"
-import { SessionID } from "../../src/session/schema"
-import { Filesystem } from "../../src/util"
-import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
-import { testEffect } from "../lib/effect"
-
-afterEach(async () => {
- await Instance.disposeAll()
-})
-
-const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
-
-const id = SessionID.make("ses_00000000000000000000000001")
-
-const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
-
-const touch = (file: string, time: number) =>
- Effect.promise(() => {
- const date = new Date(time)
- return fs.utimes(file, date, date)
- })
-
-const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
-
-const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
-
-const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
-
-const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
-
-const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
- const exit = yield* self.pipe(Effect.exit)
- if (Exit.isFailure(exit)) {
- const err = Cause.squash(exit.cause)
- return err instanceof Error ? err : new Error(String(err))
- }
- throw new Error("expected file time effect to fail")
-})
-
-describe("file/time", () => {
- describe("read() and get()", () => {
- it.live("stores read timestamp", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
-
- const before = yield* get(id, file)
- expect(before).toBeUndefined()
-
- yield* read(id, file)
-
- const after = yield* get(id, file)
- expect(after).toBeInstanceOf(Date)
- expect(after!.getTime()).toBeGreaterThan(0)
- }),
- ),
- )
-
- it.live("tracks separate timestamps per session", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
-
- const one = SessionID.make("ses_00000000000000000000000002")
- const two = SessionID.make("ses_00000000000000000000000003")
- yield* read(one, file)
- yield* read(two, file)
-
- const first = yield* get(one, file)
- const second = yield* get(two, file)
-
- expect(first).toBeDefined()
- expect(second).toBeDefined()
- }),
- ),
- )
-
- it.live("updates timestamp on subsequent reads", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
-
- yield* read(id, file)
- const first = yield* get(id, file)
-
- yield* read(id, file)
- const second = yield* get(id, file)
-
- expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
- }),
- ),
- )
-
- it.live("isolates reads by directory", () =>
- Effect.gen(function* () {
- const one = yield* tmpdirScoped()
- const two = yield* tmpdirScoped()
- const shared = yield* tmpdirScoped()
- const file = path.join(shared, "file.txt")
- yield* put(file, "content")
-
- yield* provideInstance(one)(read(id, file))
- const result = yield* provideInstance(two)(get(id, file))
- expect(result).toBeUndefined()
- }),
- )
- })
-
- describe("assert()", () => {
- it.live("passes when file has not been modified", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
- yield* touch(file, 1_000)
-
- yield* read(id, file)
- yield* check(id, file)
- }),
- ),
- )
-
- it.live("throws when file was not read first", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
-
- const err = yield* fail(check(id, file))
- expect(err.message).toContain("You must read file")
- }),
- ),
- )
-
- it.live("throws when file was modified after read", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
- yield* touch(file, 1_000)
-
- yield* read(id, file)
- yield* put(file, "modified content")
- yield* touch(file, 2_000)
-
- const err = yield* fail(check(id, file))
- expect(err.message).toContain("modified since it was last read")
- }),
- ),
- )
-
- it.live("includes timestamps in error message", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
- yield* touch(file, 1_000)
-
- yield* read(id, file)
- yield* put(file, "modified")
- yield* touch(file, 2_000)
-
- const err = yield* fail(check(id, file))
- expect(err.message).toContain("Last modification:")
- expect(err.message).toContain("Last read:")
- }),
- ),
- )
- })
-
- describe("withLock()", () => {
- it.live("executes function within lock", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- let hit = false
-
- yield* lock(file, () =>
- Effect.sync(() => {
- hit = true
- return "result"
- }),
- )
-
- expect(hit).toBe(true)
- }),
- ),
- )
-
- it.live("returns function result", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- const result = yield* lock(file, () => Effect.succeed("success"))
- expect(result).toBe("success")
- }),
- ),
- )
-
- it.live("serializes concurrent operations on same file", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- const order: number[] = []
- const hold = yield* Deferred.make<void>()
- const ready = yield* Deferred.make<void>()
-
- const one = yield* lock(file, () =>
- Effect.gen(function* () {
- order.push(1)
- yield* Deferred.succeed(ready, void 0)
- yield* Deferred.await(hold)
- order.push(2)
- }),
- ).pipe(Effect.forkScoped)
-
- yield* Deferred.await(ready)
-
- const two = yield* lock(file, () =>
- Effect.sync(() => {
- order.push(3)
- order.push(4)
- }),
- ).pipe(Effect.forkScoped)
-
- yield* Deferred.succeed(hold, void 0)
- yield* Fiber.join(one)
- yield* Fiber.join(two)
-
- expect(order).toEqual([1, 2, 3, 4])
- }),
- ),
- )
-
- it.live("allows concurrent operations on different files", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const onefile = path.join(dir, "file1.txt")
- const twofile = path.join(dir, "file2.txt")
- let one = false
- let two = false
- const hold = yield* Deferred.make<void>()
- const ready = yield* Deferred.make<void>()
-
- const a = yield* lock(onefile, () =>
- Effect.gen(function* () {
- one = true
- yield* Deferred.succeed(ready, void 0)
- yield* Deferred.await(hold)
- expect(two).toBe(true)
- }),
- ).pipe(Effect.forkScoped)
-
- yield* Deferred.await(ready)
-
- const b = yield* lock(twofile, () =>
- Effect.sync(() => {
- two = true
- }),
- ).pipe(Effect.forkScoped)
-
- yield* Fiber.join(b)
- yield* Deferred.succeed(hold, void 0)
- yield* Fiber.join(a)
-
- expect(one).toBe(true)
- expect(two).toBe(true)
- }),
- ),
- )
-
- it.live("releases lock even if function throws", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
- expect(err.message).toContain("Test error")
-
- let hit = false
- yield* lock(file, () =>
- Effect.sync(() => {
- hit = true
- }),
- )
- expect(hit).toBe(true)
- }),
- ),
- )
- })
-
- describe("path normalization", () => {
- it.live("read with forward slashes, assert with backslashes", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
- yield* touch(file, 1_000)
-
- const forward = file.replaceAll("\\", "/")
- yield* read(id, forward)
- yield* check(id, file)
- }),
- ),
- )
-
- it.live("read with backslashes, assert with forward slashes", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
- yield* touch(file, 1_000)
-
- const forward = file.replaceAll("\\", "/")
- yield* read(id, file)
- yield* check(id, forward)
- }),
- ),
- )
-
- it.live("get returns timestamp regardless of slash direction", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
-
- const forward = file.replaceAll("\\", "/")
- yield* read(id, forward)
-
- const result = yield* get(id, file)
- expect(result).toBeInstanceOf(Date)
- }),
- ),
- )
-
- it.live("withLock serializes regardless of slash direction", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- const forward = file.replaceAll("\\", "/")
- const order: number[] = []
- const hold = yield* Deferred.make<void>()
- const ready = yield* Deferred.make<void>()
-
- const one = yield* lock(file, () =>
- Effect.gen(function* () {
- order.push(1)
- yield* Deferred.succeed(ready, void 0)
- yield* Deferred.await(hold)
- order.push(2)
- }),
- ).pipe(Effect.forkScoped)
-
- yield* Deferred.await(ready)
-
- const two = yield* lock(forward, () =>
- Effect.sync(() => {
- order.push(3)
- order.push(4)
- }),
- ).pipe(Effect.forkScoped)
-
- yield* Deferred.succeed(hold, void 0)
- yield* Fiber.join(one)
- yield* Fiber.join(two)
-
- expect(order).toEqual([1, 2, 3, 4])
- }),
- ),
- )
- })
-
- describe("stat() Filesystem.stat pattern", () => {
- it.live("reads file modification time via Filesystem.stat()", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "content")
- yield* touch(file, 1_000)
-
- yield* read(id, file)
-
- const stat = Filesystem.stat(file)
- expect(stat?.mtime).toBeInstanceOf(Date)
- expect(stat!.mtime.getTime()).toBeGreaterThan(0)
-
- yield* check(id, file)
- }),
- ),
- )
-
- it.live("detects modification via stat mtime", () =>
- provideTmpdirInstance((dir) =>
- Effect.gen(function* () {
- const file = path.join(dir, "file.txt")
- yield* put(file, "original")
- yield* touch(file, 1_000)
-
- yield* read(id, file)
-
- const first = Filesystem.stat(file)
-
- yield* put(file, "modified")
- yield* touch(file, 2_000)
-
- const second = Filesystem.stat(file)
- expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
-
- yield* fail(check(id, file))
- }),
- ),
- )
- })
-})
diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts
index f124fddf9..d6eaa317f 100644
--- a/packages/opencode/test/lsp/client.test.ts
+++ b/packages/opencode/test/lsp/client.test.ts
@@ -31,6 +31,7 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
+ directory: process.cwd(),
}),
})
@@ -55,6 +56,7 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
+ directory: process.cwd(),
}),
})
@@ -79,6 +81,7 @@ describe("LSPClient interop", () => {
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
+ directory: process.cwd(),
}),
})
diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts
index 5072c1e74..83e9d71b4 100644
--- a/packages/opencode/test/plugin/loader-shared.test.ts
+++ b/packages/opencode/test/plugin/loader-shared.test.ts
@@ -239,8 +239,8 @@ describe("plugin.loader.shared", () => {
})
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
- if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
- return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
+ if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
+ return { directory: tmp.extra.scope, entrypoint: undefined }
})
try {
@@ -301,7 +301,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await load(tmp.path)
@@ -358,7 +358,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await load(tmp.path)
@@ -410,7 +410,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await load(tmp.path)
@@ -455,7 +455,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await load(tmp.path)
@@ -518,7 +518,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
await load(tmp.path)
@@ -548,7 +548,7 @@ describe("plugin.loader.shared", () => {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
try {
await load(tmp.path)
@@ -927,7 +927,7 @@ export default {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const missing: string[] = []
try {
@@ -996,7 +996,7 @@ export default {
},
})
- const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
+ const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
const loaded = await PluginLoader.loadExternal({
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index a2592286a..58dc2b0b4 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -62,6 +62,7 @@ delete process.env["AWS_PROFILE"]
delete process.env["AWS_REGION"]
delete process.env["AWS_BEARER_TOKEN_BEDROCK"]
delete process.env["OPENROUTER_API_KEY"]
+delete process.env["LLM_GATEWAY_API_KEY"]
delete process.env["GROQ_API_KEY"]
delete process.env["MISTRAL_API_KEY"]
delete process.env["PERPLEXITY_API_KEY"]
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index df8fc4e96..899302082 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => {
},
},
},
- } as ModelsDev.Provider
+ } as unknown as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
expect(model.cost.input).toEqual(5)
@@ -1934,6 +1934,38 @@ test("mode cost preserves over-200k pricing from base model", () => {
})
})
+test("models.dev normalization fills required response fields", () => {
+ const provider = {
+ id: "gateway",
+ name: "Gateway",
+ env: [],
+ models: {
+ "gpt-5.4": {
+ id: "gpt-5.4",
+ name: "GPT-5.4",
+ family: "gpt",
+ cost: {
+ input: 2.5,
+ output: 15,
+ },
+ limit: {
+ context: 1_050_000,
+ input: 922_000,
+ output: 128_000,
+ },
+ },
+ },
+ } as unknown as ModelsDev.Provider
+
+ const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"]
+ expect(model.api.url).toBe("")
+ expect(model.capabilities.temperature).toBe(false)
+ expect(model.capabilities.reasoning).toBe(false)
+ expect(model.capabilities.attachment).toBe(false)
+ expect(model.capabilities.toolcall).toBe(true)
+ expect(model.release_date).toBe("")
+})
+
test("model variants are generated for reasoning models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index e195d9b17..7a7631710 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -2804,12 +2804,14 @@ describe("ProviderTransform.variants", () => {
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "xhigh",
+ display: "summarized",
},
})
expect(result.max).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "max",
+ display: "summarized",
},
})
})
diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts
index 50b765896..23e8b5014 100644
--- a/packages/opencode/test/server/session-messages.test.ts
+++ b/packages/opencode/test/server/session-messages.test.ts
@@ -165,16 +165,3 @@ describe("session messages endpoint", () => {
)
})
})
-
-describe("session.prompt_async error handling", () => {
- test("prompt_async route has error handler for detached prompt call", async () => {
- const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
- const start = src.indexOf('"/:sessionID/prompt_async"')
- const end = src.indexOf('"/:sessionID/command"', start)
- expect(start).toBeGreaterThan(-1)
- expect(end).toBeGreaterThan(start)
- const route = src.slice(start, end)
- expect(route).toContain(".catch(")
- expect(route).toContain("Bus.publish(Session.Event.Error")
- })
-})
diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts
new file mode 100644
index 000000000..c6e8005a2
--- /dev/null
+++ b/packages/opencode/test/server/trace-attributes.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test"
+import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace"
+
+function fakeContext(method: string, url: string, params: Record<string, string>) {
+ return {
+ req: {
+ method,
+ url,
+ param: () => params,
+ },
+ }
+}
+
+describe("paramToAttributeKey", () => {
+ test("converts fooID to foo.id", () => {
+ expect(paramToAttributeKey("sessionID")).toBe("session.id")
+ expect(paramToAttributeKey("messageID")).toBe("message.id")
+ expect(paramToAttributeKey("partID")).toBe("part.id")
+ expect(paramToAttributeKey("projectID")).toBe("project.id")
+ expect(paramToAttributeKey("providerID")).toBe("provider.id")
+ expect(paramToAttributeKey("ptyID")).toBe("pty.id")
+ expect(paramToAttributeKey("permissionID")).toBe("permission.id")
+ expect(paramToAttributeKey("requestID")).toBe("request.id")
+ expect(paramToAttributeKey("workspaceID")).toBe("workspace.id")
+ })
+
+ test("namespaces non-ID params under opencode.", () => {
+ expect(paramToAttributeKey("name")).toBe("opencode.name")
+ expect(paramToAttributeKey("slug")).toBe("opencode.slug")
+ })
+})
+
+describe("requestAttributes", () => {
+ test("includes http method and path", () => {
+ const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {}))
+ expect(attrs["http.method"]).toBe("GET")
+ expect(attrs["http.path"]).toBe("/session")
+ })
+
+ test("strips query string from path", () => {
+ const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {}))
+ expect(attrs["http.path"]).toBe("/file/search")
+ })
+
+ test("emits OTel-style <domain>.id for ID-shaped route params", () => {
+ const attrs = requestAttributes(
+ fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", {
+ sessionID: "ses_abc",
+ messageID: "msg_def",
+ partID: "prt_ghi",
+ }),
+ )
+ expect(attrs["session.id"]).toBe("ses_abc")
+ expect(attrs["message.id"]).toBe("msg_def")
+ expect(attrs["part.id"]).toBe("prt_ghi")
+ // No camelCase leftovers:
+ expect(attrs["opencode.sessionID"]).toBeUndefined()
+ expect(attrs["opencode.messageID"]).toBeUndefined()
+ expect(attrs["opencode.partID"]).toBeUndefined()
+ })
+
+ test("produces no param attributes when no params are matched", () => {
+ const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {}))
+ expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([])
+ })
+
+ test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => {
+ const attrs = requestAttributes(
+ fakeContext("POST", "http://localhost/mcp/exa/connect", {
+ name: "exa",
+ }),
+ )
+ expect(attrs["opencode.name"]).toBe("exa")
+ expect(attrs["name"]).toBeUndefined()
+ })
+})
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index 6d4e994a8..55ae65c56 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { APICallError } from "ai"
import { MessageV2 } from "../../src/session/message-v2"
+import { ProviderTransform } from "../../src/provider"
import type { Provider } from "../../src/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -359,6 +360,89 @@ describe("session.message-v2.toModelMessage", () => {
])
})
+ test("preserves jpeg tool-result media for anthropic models", async () => {
+ const anthropicModel: Provider.Model = {
+ ...model,
+ id: ModelID.make("anthropic/claude-opus-4-7"),
+ providerID: ProviderID.make("anthropic"),
+ api: {
+ id: "claude-opus-4-7-20250805",
+ url: "https://api.anthropic.com",
+ npm: "@ai-sdk/anthropic",
+ },
+ capabilities: {
+ ...model.capabilities,
+ attachment: true,
+ input: {
+ ...model.capabilities.input,
+ image: true,
+ pdf: true,
+ },
+ },
+ }
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString(
+ "base64",
+ )
+ const userID = "m-user-anthropic"
+ const assistantID = "m-assistant-anthropic"
+ const input: MessageV2.WithParts[] = [
+ {
+ info: userInfo(userID),
+ parts: [
+ {
+ ...basePart(userID, "u1-anthropic"),
+ type: "text",
+ text: "run tool",
+ },
+ ] as MessageV2.Part[],
+ },
+ {
+ info: assistantInfo(assistantID, userID),
+ parts: [
+ {
+ ...basePart(assistantID, "a1-anthropic"),
+ type: "tool",
+ callID: "call-anthropic-1",
+ tool: "read",
+ state: {
+ status: "completed",
+ input: { filePath: "/tmp/rails-demo.png" },
+ output: "Image read successfully",
+ title: "Read",
+ metadata: {},
+ time: { start: 0, end: 1 },
+ attachments: [
+ {
+ ...basePart(assistantID, "file-anthropic-1"),
+ type: "file",
+ mime: "image/jpeg",
+ filename: "rails-demo.png",
+ url: `data:image/jpeg;base64,${jpeg}`,
+ },
+ ],
+ },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {})
+ expect(result).toHaveLength(3)
+ expect(result[2].role).toBe("tool")
+ expect(result[2].content[0]).toMatchObject({
+ type: "tool-result",
+ toolCallId: "call-anthropic-1",
+ toolName: "read",
+ output: {
+ type: "content",
+ value: [
+ { type: "text", text: "Image read successfully" },
+ { type: "media", mediaType: "image/jpeg", data: jpeg },
+ ],
+ },
+ })
+ })
+
test("omits provider metadata when assistant model differs", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index 121d662e5..2f5904684 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -7,7 +7,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config"
-import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
@@ -148,16 +147,6 @@ const lsp = Layer.succeed(
}),
)
-const filetime = Layer.succeed(
- FileTime.Service,
- FileTime.Service.of({
- read: () => Effect.void,
- get: () => Effect.succeed(undefined),
- assert: () => Effect.void,
- withLock: (_filepath, fn) => fn(),
- }),
-)
-
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
const run = SessionRunState.layer.pipe(Layer.provide(status))
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
@@ -173,7 +162,6 @@ function makeHttp() {
Plugin.defaultLayer,
Config.defaultLayer,
ProviderSvc.defaultLayer,
- filetime,
lsp,
mcp,
AppFileSystem.defaultLayer,
diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts
new file mode 100644
index 000000000..defce40c1
--- /dev/null
+++ b/packages/opencode/test/session/session-entry-stepper.test.ts
@@ -0,0 +1,916 @@
+import { describe, expect, test } from "bun:test"
+import * as DateTime from "effect/DateTime"
+import * as FastCheck from "effect/testing/FastCheck"
+import { SessionEntry } from "../../src/v2/session-entry"
+import { SessionEntryStepper } from "../../src/v2/session-entry-stepper"
+import { SessionEvent } from "../../src/v2/session-event"
+
+const time = (n: number) => DateTime.makeUnsafe(n)
+
+const word = FastCheck.string({ minLength: 1, maxLength: 8 })
+const text = FastCheck.string({ maxLength: 16 })
+const texts = FastCheck.array(text, { maxLength: 8 })
+const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
+const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
+const files = FastCheck.array(
+ word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
+ { maxLength: 2 },
+)
+
+function maybe<A>(arb: FastCheck.Arbitrary<A>) {
+ return FastCheck.oneof(FastCheck.constant(undefined), arb)
+}
+
+function assistant() {
+ return new SessionEntry.Assistant({
+ id: SessionEvent.ID.create(),
+ type: "assistant",
+ time: { created: time(0) },
+ content: [],
+ retries: [],
+ })
+}
+
+function retryError(message: string) {
+ return new SessionEvent.RetryError({
+ message,
+ isRetryable: true,
+ })
+}
+
+function retry(attempt: number, message: string, created: number) {
+ return new SessionEntry.AssistantRetry({
+ attempt,
+ error: retryError(message),
+ time: {
+ created: time(created),
+ },
+ })
+}
+
+function memoryState() {
+ const state: SessionEntryStepper.MemoryState = {
+ entries: [],
+ pending: [],
+ }
+ return state
+}
+
+function active() {
+ const state: SessionEntryStepper.MemoryState = {
+ entries: [assistant()],
+ pending: [],
+ }
+ return state
+}
+
+function run(events: SessionEvent.Event[], state = memoryState()) {
+ return events.reduce<SessionEntryStepper.MemoryState>((state, event) => SessionEntryStepper.step(state, event), state)
+}
+
+function last(state: SessionEntryStepper.MemoryState) {
+ const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
+ expect(entry?.type).toBe("assistant")
+ return entry?.type === "assistant" ? entry : undefined
+}
+
+function texts_of(state: SessionEntryStepper.MemoryState) {
+ const entry = last(state)
+ if (!entry) return []
+ return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
+}
+
+function reasons(state: SessionEntryStepper.MemoryState) {
+ const entry = last(state)
+ if (!entry) return []
+ return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
+}
+
+function tools(state: SessionEntryStepper.MemoryState) {
+ const entry = last(state)
+ if (!entry) return []
+ return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
+}
+
+function tool(state: SessionEntryStepper.MemoryState, callID: string) {
+ return tools(state).find((x) => x.callID === callID)
+}
+
+function retriesOf(state: SessionEntryStepper.MemoryState) {
+ const entry = last(state)
+ if (!entry) return []
+ return entry.retries ?? []
+}
+
+function adapterStore() {
+ return {
+ committed: [] as SessionEntry.Entry[],
+ deferred: [] as SessionEntry.Entry[],
+ }
+}
+
+function adapterFor(store: ReturnType<typeof adapterStore>): SessionEntryStepper.Adapter<typeof store> {
+ const activeAssistantIndex = () =>
+ store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
+
+ const getCurrentAssistant = () => {
+ const index = activeAssistantIndex()
+ if (index < 0) return
+ const assistant = store.committed[index]
+ return assistant?.type === "assistant" ? assistant : undefined
+ }
+
+ return {
+ getCurrentAssistant,
+ updateAssistant(assistant) {
+ const index = activeAssistantIndex()
+ if (index < 0) return
+ const current = store.committed[index]
+ if (current?.type !== "assistant") return
+ store.committed[index] = assistant
+ },
+ appendEntry(entry) {
+ store.committed.push(entry)
+ },
+ appendPending(entry) {
+ store.deferred.push(entry)
+ },
+ finish() {
+ return store
+ },
+ }
+}
+
+describe("session-entry-stepper", () => {
+ describe("stepWith", () => {
+ test("reduces through a custom adapter", () => {
+ const store = adapterStore()
+ store.committed.push(assistant())
+
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) }))
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) }))
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }),
+ )
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }),
+ )
+ SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) }))
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }),
+ )
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Step.Ended.create({
+ reason: "stop",
+ cost: 1,
+ tokens: {
+ input: 1,
+ output: 2,
+ reasoning: 3,
+ cache: {
+ read: 4,
+ write: 5,
+ },
+ },
+ timestamp: time(7),
+ }),
+ )
+
+ expect(store.deferred).toHaveLength(1)
+ expect(store.deferred[0]?.type).toBe("user")
+ expect(store.committed).toHaveLength(1)
+ expect(store.committed[0]?.type).toBe("assistant")
+ if (store.committed[0]?.type !== "assistant") return
+
+ expect(store.committed[0].content).toEqual([
+ { type: "reasoning", text: "thought" },
+ { type: "text", text: "world" },
+ ])
+ expect(store.committed[0].time.completed).toEqual(time(7))
+ })
+
+ test("aggregates retry events onto the current assistant", () => {
+ const store = adapterStore()
+ store.committed.push(assistant())
+
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Retried.create({
+ attempt: 1,
+ error: retryError("rate limited"),
+ timestamp: time(1),
+ }),
+ )
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Retried.create({
+ attempt: 2,
+ error: retryError("provider overloaded"),
+ timestamp: time(2),
+ }),
+ )
+
+ expect(store.committed[0]?.type).toBe("assistant")
+ if (store.committed[0]?.type !== "assistant") return
+
+ expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
+ })
+ })
+
+ describe("memory", () => {
+ test("tracks and replaces the current assistant", () => {
+ const state = active()
+ const adapter = SessionEntryStepper.memory(state)
+ const current = adapter.getCurrentAssistant()
+
+ expect(current?.type).toBe("assistant")
+ if (!current) return
+
+ adapter.updateAssistant(
+ new SessionEntry.Assistant({
+ ...current,
+ content: [new SessionEntry.AssistantText({ type: "text", text: "done" })],
+ time: {
+ ...current.time,
+ completed: time(1),
+ },
+ }),
+ )
+
+ expect(adapter.getCurrentAssistant()).toBeUndefined()
+ expect(state.entries[0]?.type).toBe("assistant")
+ if (state.entries[0]?.type !== "assistant") return
+
+ expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }])
+ expect(state.entries[0].time.completed).toEqual(time(1))
+ })
+
+ test("appends committed and pending entries", () => {
+ const state = memoryState()
+ const adapter = SessionEntryStepper.memory(state)
+ const committed = SessionEntry.User.fromEvent(
+ SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }),
+ )
+ const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) }))
+
+ adapter.appendEntry(committed)
+ adapter.appendPending(pending)
+
+ expect(state.entries).toEqual([committed])
+ expect(state.pending).toEqual([pending])
+ })
+
+ test("stepWith through memory records reasoning", () => {
+ const state = active()
+
+ SessionEntryStepper.stepWith(
+ SessionEntryStepper.memory(state),
+ SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
+ )
+ SessionEntryStepper.stepWith(
+ SessionEntryStepper.memory(state),
+ SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }),
+ )
+ SessionEntryStepper.stepWith(
+ SessionEntryStepper.memory(state),
+ SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }),
+ )
+
+ expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
+ })
+
+ test("stepWith through memory records retries", () => {
+ const state = active()
+
+ SessionEntryStepper.stepWith(
+ SessionEntryStepper.memory(state),
+ SessionEvent.Retried.create({
+ attempt: 1,
+ error: retryError("rate limited"),
+ timestamp: time(1),
+ }),
+ )
+
+ expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
+ })
+ })
+
+ describe("step", () => {
+ describe("seeded pending assistant", () => {
+ test("stores prompts in entries when no assistant is pending", () => {
+ FastCheck.assert(
+ FastCheck.property(word, (body) => {
+ const next = SessionEntryStepper.step(
+ memoryState(),
+ SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
+ )
+ expect(next.entries).toHaveLength(1)
+ expect(next.entries[0]?.type).toBe("user")
+ if (next.entries[0]?.type !== "user") return
+ expect(next.entries[0].text).toBe(body)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("stores prompts in pending when an assistant is pending", () => {
+ FastCheck.assert(
+ FastCheck.property(word, (body) => {
+ const next = SessionEntryStepper.step(
+ active(),
+ SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
+ )
+ expect(next.pending).toHaveLength(1)
+ expect(next.pending[0]?.type).toBe("user")
+ if (next.pending[0]?.type !== "user") return
+ expect(next.pending[0].text).toBe(body)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("accumulates text deltas on the latest text part", () => {
+ FastCheck.assert(
+ FastCheck.property(texts, (parts) => {
+ const next = parts.reduce(
+ (state, part, i) =>
+ SessionEntryStepper.step(
+ state,
+ SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }),
+ ),
+ SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
+ )
+
+ expect(texts_of(next)).toEqual([
+ {
+ type: "text",
+ text: parts.join(""),
+ },
+ ])
+ }),
+ { numRuns: 100 },
+ )
+ })
+
+ test("routes later text deltas to the latest text segment", () => {
+ FastCheck.assert(
+ FastCheck.property(texts, texts, (a, b) => {
+ const next = run(
+ [
+ SessionEvent.Text.Started.create({ timestamp: time(1) }),
+ ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
+ SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
+ ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
+ ],
+ active(),
+ )
+
+ expect(texts_of(next)).toEqual([
+ { type: "text", text: a.join("") },
+ { type: "text", text: b.join("") },
+ ])
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("reasoning.ended replaces buffered reasoning text", () => {
+ FastCheck.assert(
+ FastCheck.property(texts, text, (parts, end) => {
+ const next = run(
+ [
+ SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
+ ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
+ SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
+ ],
+ active(),
+ )
+
+ expect(reasons(next)).toEqual([
+ {
+ type: "reasoning",
+ text: end,
+ },
+ ])
+ }),
+ { numRuns: 100 },
+ )
+ })
+
+ test("tool.success completes the latest running tool", () => {
+ FastCheck.assert(
+ FastCheck.property(
+ word,
+ word,
+ dict,
+ maybe(text),
+ maybe(dict),
+ maybe(files),
+ texts,
+ (callID, title, input, output, metadata, attachments, parts) => {
+ const next = run(
+ [
+ SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
+ ...parts.map((x, i) =>
+ SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
+ ),
+ SessionEvent.Tool.Called.create({
+ callID,
+ tool: "bash",
+ input,
+ provider: { executed: true },
+ timestamp: time(parts.length + 2),
+ }),
+ SessionEvent.Tool.Success.create({
+ callID,
+ title,
+ output,
+ metadata,
+ attachments,
+ provider: { executed: true },
+ timestamp: time(parts.length + 3),
+ }),
+ ],
+ active(),
+ )
+
+ const match = tool(next, callID)
+ expect(match?.state.status).toBe("completed")
+ if (match?.state.status !== "completed") return
+
+ expect(match.time.ran).toEqual(time(parts.length + 2))
+ expect(match.state.input).toEqual(input)
+ expect(match.state.output).toBe(output ?? "")
+ expect(match.state.title).toBe(title)
+ expect(match.state.metadata).toEqual(metadata ?? {})
+ expect(match.state.attachments).toEqual(attachments ?? [])
+ },
+ ),
+ { numRuns: 50 },
+ )
+ })
+
+ test("tool.error completes the latest running tool with an error", () => {
+ FastCheck.assert(
+ FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
+ const next = run(
+ [
+ SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
+ SessionEvent.Tool.Called.create({
+ callID,
+ tool: "bash",
+ input,
+ provider: { executed: true },
+ timestamp: time(2),
+ }),
+ SessionEvent.Tool.Error.create({
+ callID,
+ error,
+ metadata,
+ provider: { executed: true },
+ timestamp: time(3),
+ }),
+ ],
+ active(),
+ )
+
+ const match = tool(next, callID)
+ expect(match?.state.status).toBe("error")
+ if (match?.state.status !== "error") return
+
+ expect(match.time.ran).toEqual(time(2))
+ expect(match.state.input).toEqual(input)
+ expect(match.state.error).toBe(error)
+ expect(match.state.metadata).toEqual(metadata ?? {})
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("tool.success is ignored before tool.called promotes the tool to running", () => {
+ FastCheck.assert(
+ FastCheck.property(word, word, (callID, title) => {
+ const next = run(
+ [
+ SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
+ SessionEvent.Tool.Success.create({
+ callID,
+ title,
+ provider: { executed: true },
+ timestamp: time(2),
+ }),
+ ],
+ active(),
+ )
+ const match = tool(next, callID)
+ expect(match?.state).toEqual({
+ status: "pending",
+ input: "",
+ })
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("step.ended copies completion fields onto the pending assistant", () => {
+ FastCheck.assert(
+ FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
+ const event = SessionEvent.Step.Ended.create({
+ reason: "stop",
+ cost: 1,
+ tokens: {
+ input: 1,
+ output: 2,
+ reasoning: 3,
+ cache: {
+ read: 4,
+ write: 5,
+ },
+ },
+ timestamp: time(n),
+ })
+ const next = SessionEntryStepper.step(active(), event)
+ const entry = last(next)
+ expect(entry).toBeDefined()
+ if (!entry) return
+
+ expect(entry.time.completed).toEqual(event.timestamp)
+ expect(entry.cost).toBe(event.cost)
+ expect(entry.tokens).toEqual(event.tokens)
+ }),
+ { numRuns: 50 },
+ )
+ })
+ })
+
+ describe("known reducer gaps", () => {
+ test("prompt appends immutably when no assistant is pending", () => {
+ FastCheck.assert(
+ FastCheck.property(word, (body) => {
+ const old = memoryState()
+ const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
+ expect(old).not.toBe(next)
+ expect(old.entries).toHaveLength(0)
+ expect(next.entries).toHaveLength(1)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("prompt appends immutably when an assistant is pending", () => {
+ FastCheck.assert(
+ FastCheck.property(word, (body) => {
+ const old = active()
+ const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
+ expect(old).not.toBe(next)
+ expect(old.pending).toHaveLength(0)
+ expect(next.pending).toHaveLength(1)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("step.started creates an assistant consumed by follow-up events", () => {
+ FastCheck.assert(
+ FastCheck.property(texts, (parts) => {
+ const next = run([
+ SessionEvent.Step.Started.create({
+ model: {
+ id: "model",
+ providerID: "provider",
+ },
+ timestamp: time(1),
+ }),
+ SessionEvent.Text.Started.create({ timestamp: time(2) }),
+ ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
+ SessionEvent.Step.Ended.create({
+ reason: "stop",
+ cost: 1,
+ tokens: {
+ input: 1,
+ output: 2,
+ reasoning: 3,
+ cache: {
+ read: 4,
+ write: 5,
+ },
+ },
+ timestamp: time(parts.length + 3),
+ }),
+ ])
+ const entry = last(next)
+
+ expect(entry).toBeDefined()
+ if (!entry) return
+
+ expect(entry.content).toEqual([
+ {
+ type: "text",
+ text: parts.join(""),
+ },
+ ])
+ expect(entry.time.completed).toEqual(time(parts.length + 3))
+ }),
+ { numRuns: 100 },
+ )
+ })
+
+ test("replays prompt -> step -> text -> step.ended", () => {
+ FastCheck.assert(
+ FastCheck.property(word, texts, (body, parts) => {
+ const next = run([
+ SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
+ SessionEvent.Step.Started.create({
+ model: {
+ id: "model",
+ providerID: "provider",
+ },
+ timestamp: time(1),
+ }),
+ SessionEvent.Text.Started.create({ timestamp: time(2) }),
+ ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
+ SessionEvent.Step.Ended.create({
+ reason: "stop",
+ cost: 1,
+ tokens: {
+ input: 1,
+ output: 2,
+ reasoning: 3,
+ cache: {
+ read: 4,
+ write: 5,
+ },
+ },
+ timestamp: time(parts.length + 3),
+ }),
+ ])
+
+ expect(next.entries).toHaveLength(2)
+ expect(next.entries[0]?.type).toBe("user")
+ expect(next.entries[1]?.type).toBe("assistant")
+ if (next.entries[1]?.type !== "assistant") return
+
+ expect(next.entries[1].content).toEqual([
+ {
+ type: "text",
+ text: parts.join(""),
+ },
+ ])
+ expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
+ FastCheck.assert(
+ FastCheck.property(
+ word,
+ texts,
+ text,
+ dict,
+ word,
+ maybe(text),
+ maybe(dict),
+ maybe(files),
+ (body, reason, end, input, title, output, metadata, attachments) => {
+ const callID = "call"
+ const next = run([
+ SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
+ SessionEvent.Step.Started.create({
+ model: {
+ id: "model",
+ providerID: "provider",
+ },
+ timestamp: time(1),
+ }),
+ SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
+ ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
+ SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
+ SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
+ SessionEvent.Tool.Called.create({
+ callID,
+ tool: "bash",
+ input,
+ provider: { executed: true },
+ timestamp: time(reason.length + 5),
+ }),
+ SessionEvent.Tool.Success.create({
+ callID,
+ title,
+ output,
+ metadata,
+ attachments,
+ provider: { executed: true },
+ timestamp: time(reason.length + 6),
+ }),
+ SessionEvent.Step.Ended.create({
+ reason: "stop",
+ cost: 1,
+ tokens: {
+ input: 1,
+ output: 2,
+ reasoning: 3,
+ cache: {
+ read: 4,
+ write: 5,
+ },
+ },
+ timestamp: time(reason.length + 7),
+ }),
+ ])
+
+ expect(next.entries.at(-1)?.type).toBe("assistant")
+ const entry = next.entries.at(-1)
+ if (entry?.type !== "assistant") return
+
+ expect(entry.content).toHaveLength(2)
+ expect(entry.content[0]).toEqual({
+ type: "reasoning",
+ text: end,
+ })
+ expect(entry.content[1]?.type).toBe("tool")
+ if (entry.content[1]?.type !== "tool") return
+ expect(entry.content[1].state.status).toBe("completed")
+ expect(entry.time.completed).toEqual(time(reason.length + 7))
+ },
+ ),
+ { numRuns: 50 },
+ )
+ })
+
+ test("starting a new step completes the old assistant and appends a new active assistant", () => {
+ const next = run(
+ [
+ SessionEvent.Step.Started.create({
+ model: {
+ id: "model",
+ providerID: "provider",
+ },
+ timestamp: time(1),
+ }),
+ ],
+ active(),
+ )
+ expect(next.entries).toHaveLength(2)
+ expect(next.entries[0]?.type).toBe("assistant")
+ expect(next.entries[1]?.type).toBe("assistant")
+ if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
+
+ expect(next.entries[0].time.completed).toEqual(time(1))
+ expect(next.entries[1].time.created).toEqual(time(1))
+ expect(next.entries[1].time.completed).toBeUndefined()
+ })
+
+ test("handles sequential tools independently", () => {
+ FastCheck.assert(
+ FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
+ const next = run(
+ [
+ SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
+ SessionEvent.Tool.Called.create({
+ callID: "a",
+ tool: "bash",
+ input: a,
+ provider: { executed: true },
+ timestamp: time(2),
+ }),
+ SessionEvent.Tool.Success.create({
+ callID: "a",
+ title,
+ output: "done",
+ provider: { executed: true },
+ timestamp: time(3),
+ }),
+ SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
+ SessionEvent.Tool.Called.create({
+ callID: "b",
+ tool: "bash",
+ input: b,
+ provider: { executed: true },
+ timestamp: time(5),
+ }),
+ SessionEvent.Tool.Error.create({
+ callID: "b",
+ error,
+ provider: { executed: true },
+ timestamp: time(6),
+ }),
+ ],
+ active(),
+ )
+
+ const first = tool(next, "a")
+ const second = tool(next, "b")
+
+ expect(first?.state.status).toBe("completed")
+ if (first?.state.status !== "completed") return
+ expect(first.state.input).toEqual(a)
+ expect(first.state.output).toBe("done")
+ expect(first.state.title).toBe(title)
+
+ expect(second?.state.status).toBe("error")
+ if (second?.state.status !== "error") return
+ expect(second.state.input).toEqual(b)
+ expect(second.state.error).toBe(error)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("routes tool events by callID when tool streams interleave", () => {
+ FastCheck.assert(
+ FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
+ const next = run(
+ [
+ SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
+ SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
+ SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
+ SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
+ SessionEvent.Tool.Called.create({
+ callID: "a",
+ tool: "bash",
+ input: a,
+ provider: { executed: true },
+ timestamp: time(5),
+ }),
+ SessionEvent.Tool.Called.create({
+ callID: "b",
+ tool: "grep",
+ input: b,
+ provider: { executed: true },
+ timestamp: time(6),
+ }),
+ SessionEvent.Tool.Success.create({
+ callID: "a",
+ title: titleA,
+ output: "done-a",
+ provider: { executed: true },
+ timestamp: time(7),
+ }),
+ SessionEvent.Tool.Success.create({
+ callID: "b",
+ title: titleB,
+ output: "done-b",
+ provider: { executed: true },
+ timestamp: time(8),
+ }),
+ ],
+ active(),
+ )
+
+ const first = tool(next, "a")
+ const second = tool(next, "b")
+
+ expect(first?.state.status).toBe("completed")
+ expect(second?.state.status).toBe("completed")
+ if (first?.state.status !== "completed" || second?.state.status !== "completed") return
+
+ expect(first.state.input).toEqual(a)
+ expect(second.state.input).toEqual(b)
+ expect(first.state.title).toBe(titleA)
+ expect(second.state.title).toBe(titleB)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("records synthetic events", () => {
+ FastCheck.assert(
+ FastCheck.property(word, (body) => {
+ const next = SessionEntryStepper.step(
+ memoryState(),
+ SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }),
+ )
+ expect(next.entries).toHaveLength(1)
+ expect(next.entries[0]?.type).toBe("synthetic")
+ if (next.entries[0]?.type !== "synthetic") return
+ expect(next.entries[0].text).toBe(body)
+ }),
+ { numRuns: 50 },
+ )
+ })
+
+ test("records compaction events", () => {
+ FastCheck.assert(
+ FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
+ const next = SessionEntryStepper.step(
+ memoryState(),
+ SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
+ )
+ expect(next.entries).toHaveLength(1)
+ expect(next.entries[0]?.type).toBe("compaction")
+ if (next.entries[0]?.type !== "compaction") return
+ expect(next.entries[0].auto).toBe(auto)
+ expect(next.entries[0].overflow).toBe(overflow)
+ }),
+ { numRuns: 50 },
+ )
+ })
+ })
+ })
+})
diff --git a/packages/opencode/test/session/session-entry.test.ts b/packages/opencode/test/session/session-entry.test.ts
deleted file mode 100644
index 7eba3900d..000000000
--- a/packages/opencode/test/session/session-entry.test.ts
+++ /dev/null
@@ -1,624 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import * as DateTime from "effect/DateTime"
-import * as FastCheck from "effect/testing/FastCheck"
-import { SessionEntry } from "../../src/v2/session-entry"
-import { SessionEvent } from "../../src/v2/session-event"
-
-const time = (n: number) => DateTime.makeUnsafe(n)
-
-const word = FastCheck.string({ minLength: 1, maxLength: 8 })
-const text = FastCheck.string({ maxLength: 16 })
-const texts = FastCheck.array(text, { maxLength: 8 })
-const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
-const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
-const files = FastCheck.array(
- word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
- { maxLength: 2 },
-)
-
-function maybe<A>(arb: FastCheck.Arbitrary<A>) {
- return FastCheck.oneof(FastCheck.constant(undefined), arb)
-}
-
-function assistant() {
- return new SessionEntry.Assistant({
- id: SessionEvent.ID.create(),
- type: "assistant",
- time: { created: time(0) },
- content: [],
- })
-}
-
-function history() {
- const state: SessionEntry.History = {
- entries: [],
- pending: [],
- }
- return state
-}
-
-function active() {
- const state: SessionEntry.History = {
- entries: [assistant()],
- pending: [],
- }
- return state
-}
-
-function run(events: SessionEvent.Event[], state = history()) {
- return events.reduce<SessionEntry.History>((state, event) => SessionEntry.step(state, event), state)
-}
-
-function last(state: SessionEntry.History) {
- const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
- expect(entry?.type).toBe("assistant")
- return entry?.type === "assistant" ? entry : undefined
-}
-
-function texts_of(state: SessionEntry.History) {
- const entry = last(state)
- if (!entry) return []
- return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
-}
-
-function reasons(state: SessionEntry.History) {
- const entry = last(state)
- if (!entry) return []
- return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
-}
-
-function tools(state: SessionEntry.History) {
- const entry = last(state)
- if (!entry) return []
- return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
-}
-
-function tool(state: SessionEntry.History, callID: string) {
- return tools(state).find((x) => x.callID === callID)
-}
-
-describe("session-entry step", () => {
- describe("seeded pending assistant", () => {
- test("stores prompts in entries when no assistant is pending", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
- expect(next.entries).toHaveLength(1)
- expect(next.entries[0]?.type).toBe("user")
- if (next.entries[0]?.type !== "user") return
- expect(next.entries[0].text).toBe(body)
- }),
- { numRuns: 50 },
- )
- })
-
- test("stores prompts in pending when an assistant is pending", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
- expect(next.pending).toHaveLength(1)
- expect(next.pending[0]?.type).toBe("user")
- if (next.pending[0]?.type !== "user") return
- expect(next.pending[0].text).toBe(body)
- }),
- { numRuns: 50 },
- )
- })
-
- test("accumulates text deltas on the latest text part", () => {
- FastCheck.assert(
- FastCheck.property(texts, (parts) => {
- const next = parts.reduce(
- (state, part, i) =>
- SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
- SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
- )
-
- expect(texts_of(next)).toEqual([
- {
- type: "text",
- text: parts.join(""),
- },
- ])
- }),
- { numRuns: 100 },
- )
- })
-
- test("routes later text deltas to the latest text segment", () => {
- FastCheck.assert(
- FastCheck.property(texts, texts, (a, b) => {
- const next = run(
- [
- SessionEvent.Text.Started.create({ timestamp: time(1) }),
- ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
- SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
- ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
- ],
- active(),
- )
-
- expect(texts_of(next)).toEqual([
- { type: "text", text: a.join("") },
- { type: "text", text: b.join("") },
- ])
- }),
- { numRuns: 50 },
- )
- })
-
- test("reasoning.ended replaces buffered reasoning text", () => {
- FastCheck.assert(
- FastCheck.property(texts, text, (parts, end) => {
- const next = run(
- [
- SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
- ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
- SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
- ],
- active(),
- )
-
- expect(reasons(next)).toEqual([
- {
- type: "reasoning",
- text: end,
- },
- ])
- }),
- { numRuns: 100 },
- )
- })
-
- test("tool.success completes the latest running tool", () => {
- FastCheck.assert(
- FastCheck.property(
- word,
- word,
- dict,
- maybe(text),
- maybe(dict),
- maybe(files),
- texts,
- (callID, title, input, output, metadata, attachments, parts) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
- ...parts.map((x, i) =>
- SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
- ),
- SessionEvent.Tool.Called.create({
- callID,
- tool: "bash",
- input,
- provider: { executed: true },
- timestamp: time(parts.length + 2),
- }),
- SessionEvent.Tool.Success.create({
- callID,
- title,
- output,
- metadata,
- attachments,
- provider: { executed: true },
- timestamp: time(parts.length + 3),
- }),
- ],
- active(),
- )
-
- const match = tool(next, callID)
- expect(match?.state.status).toBe("completed")
- if (match?.state.status !== "completed") return
-
- expect(match.time.ran).toEqual(time(parts.length + 2))
- expect(match.state.input).toEqual(input)
- expect(match.state.output).toBe(output ?? "")
- expect(match.state.title).toBe(title)
- expect(match.state.metadata).toEqual(metadata ?? {})
- expect(match.state.attachments).toEqual(attachments ?? [])
- },
- ),
- { numRuns: 50 },
- )
- })
-
- test("tool.error completes the latest running tool with an error", () => {
- FastCheck.assert(
- FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Called.create({
- callID,
- tool: "bash",
- input,
- provider: { executed: true },
- timestamp: time(2),
- }),
- SessionEvent.Tool.Error.create({
- callID,
- error,
- metadata,
- provider: { executed: true },
- timestamp: time(3),
- }),
- ],
- active(),
- )
-
- const match = tool(next, callID)
- expect(match?.state.status).toBe("error")
- if (match?.state.status !== "error") return
-
- expect(match.time.ran).toEqual(time(2))
- expect(match.state.input).toEqual(input)
- expect(match.state.error).toBe(error)
- expect(match.state.metadata).toEqual(metadata ?? {})
- }),
- { numRuns: 50 },
- )
- })
-
- test("tool.success is ignored before tool.called promotes the tool to running", () => {
- FastCheck.assert(
- FastCheck.property(word, word, (callID, title) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Success.create({
- callID,
- title,
- provider: { executed: true },
- timestamp: time(2),
- }),
- ],
- active(),
- )
- const match = tool(next, callID)
- expect(match?.state).toEqual({
- status: "pending",
- input: "",
- })
- }),
- { numRuns: 50 },
- )
- })
-
- test("step.ended copies completion fields onto the pending assistant", () => {
- FastCheck.assert(
- FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
- const event = SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(n),
- })
- const next = SessionEntry.step(active(), event)
- const entry = last(next)
- expect(entry).toBeDefined()
- if (!entry) return
-
- expect(entry.time.completed).toEqual(event.timestamp)
- expect(entry.cost).toBe(event.cost)
- expect(entry.tokens).toEqual(event.tokens)
- }),
- { numRuns: 50 },
- )
- })
- })
-
- describe("known reducer gaps", () => {
- test("prompt appends immutably when no assistant is pending", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const old = history()
- const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
- expect(old).not.toBe(next)
- expect(old.entries).toHaveLength(0)
- expect(next.entries).toHaveLength(1)
- }),
- { numRuns: 50 },
- )
- })
-
- test("prompt appends immutably when an assistant is pending", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const old = active()
- const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
- expect(old).not.toBe(next)
- expect(old.pending).toHaveLength(0)
- expect(next.pending).toHaveLength(1)
- }),
- { numRuns: 50 },
- )
- })
-
- test("step.started creates an assistant consumed by follow-up events", () => {
- FastCheck.assert(
- FastCheck.property(texts, (parts) => {
- const next = run([
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- SessionEvent.Text.Started.create({ timestamp: time(2) }),
- ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
- SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(parts.length + 3),
- }),
- ])
- const entry = last(next)
-
- expect(entry).toBeDefined()
- if (!entry) return
-
- expect(entry.content).toEqual([
- {
- type: "text",
- text: parts.join(""),
- },
- ])
- expect(entry.time.completed).toEqual(time(parts.length + 3))
- }),
- { numRuns: 100 },
- )
- })
-
- test("replays prompt -> step -> text -> step.ended", () => {
- FastCheck.assert(
- FastCheck.property(word, texts, (body, parts) => {
- const next = run([
- SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- SessionEvent.Text.Started.create({ timestamp: time(2) }),
- ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
- SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(parts.length + 3),
- }),
- ])
-
- expect(next.entries).toHaveLength(2)
- expect(next.entries[0]?.type).toBe("user")
- expect(next.entries[1]?.type).toBe("assistant")
- if (next.entries[1]?.type !== "assistant") return
-
- expect(next.entries[1].content).toEqual([
- {
- type: "text",
- text: parts.join(""),
- },
- ])
- expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
- }),
- { numRuns: 50 },
- )
- })
-
- test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
- FastCheck.assert(
- FastCheck.property(
- word,
- texts,
- text,
- dict,
- word,
- maybe(text),
- maybe(dict),
- maybe(files),
- (body, reason, end, input, title, output, metadata, attachments) => {
- const callID = "call"
- const next = run([
- SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
- ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
- SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
- SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
- SessionEvent.Tool.Called.create({
- callID,
- tool: "bash",
- input,
- provider: { executed: true },
- timestamp: time(reason.length + 5),
- }),
- SessionEvent.Tool.Success.create({
- callID,
- title,
- output,
- metadata,
- attachments,
- provider: { executed: true },
- timestamp: time(reason.length + 6),
- }),
- SessionEvent.Step.Ended.create({
- reason: "stop",
- cost: 1,
- tokens: {
- input: 1,
- output: 2,
- reasoning: 3,
- cache: {
- read: 4,
- write: 5,
- },
- },
- timestamp: time(reason.length + 7),
- }),
- ])
-
- expect(next.entries.at(-1)?.type).toBe("assistant")
- const entry = next.entries.at(-1)
- if (entry?.type !== "assistant") return
-
- expect(entry.content).toHaveLength(2)
- expect(entry.content[0]).toEqual({
- type: "reasoning",
- text: end,
- })
- expect(entry.content[1]?.type).toBe("tool")
- if (entry.content[1]?.type !== "tool") return
- expect(entry.content[1].state.status).toBe("completed")
- expect(entry.time.completed).toEqual(time(reason.length + 7))
- },
- ),
- { numRuns: 50 },
- )
- })
-
- test("starting a new step completes the old assistant and appends a new active assistant", () => {
- const next = run(
- [
- SessionEvent.Step.Started.create({
- model: {
- id: "model",
- providerID: "provider",
- },
- timestamp: time(1),
- }),
- ],
- active(),
- )
- expect(next.entries).toHaveLength(2)
- expect(next.entries[0]?.type).toBe("assistant")
- expect(next.entries[1]?.type).toBe("assistant")
- if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
-
- expect(next.entries[0].time.completed).toEqual(time(1))
- expect(next.entries[1].time.created).toEqual(time(1))
- expect(next.entries[1].time.completed).toBeUndefined()
- })
-
- test("handles sequential tools independently", () => {
- FastCheck.assert(
- FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
- const next = run(
- [
- SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
- SessionEvent.Tool.Called.create({
- callID: "a",
- tool: "bash",
- input: a,
- provider: { executed: true },
- timestamp: time(2),
- }),
- SessionEvent.Tool.Success.create({
- callID: "a",
- title,
- output: "done",
- provider: { executed: true },
- timestamp: time(3),
- }),
- SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
- SessionEvent.Tool.Called.create({
- callID: "b",
- tool: "bash",
- input: b,
- provider: { executed: true },
- timestamp: time(5),
- }),
- SessionEvent.Tool.Error.create({
- callID: "b",
- error,
- provider: { executed: true },
- timestamp: time(6),
- }),
- ],
- active(),
- )
-
- const first = tool(next, "a")
- const second = tool(next, "b")
-
- expect(first?.state.status).toBe("completed")
- if (first?.state.status !== "completed") return
- expect(first.state.input).toEqual(a)
- expect(first.state.output).toBe("done")
- expect(first.state.title).toBe(title)
-
- expect(second?.state.status).toBe("error")
- if (second?.state.status !== "error") return
- expect(second.state.input).toEqual(b)
- expect(second.state.error).toBe(error)
- }),
- { numRuns: 50 },
- )
- })
-
- test.failing("records synthetic events", () => {
- FastCheck.assert(
- FastCheck.property(word, (body) => {
- const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
- expect(next.entries).toHaveLength(1)
- expect(next.entries[0]?.type).toBe("synthetic")
- if (next.entries[0]?.type !== "synthetic") return
- expect(next.entries[0].text).toBe(body)
- }),
- { numRuns: 50 },
- )
- })
-
- test.failing("records compaction events", () => {
- FastCheck.assert(
- FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
- const next = SessionEntry.step(
- history(),
- SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
- )
- expect(next.entries).toHaveLength(1)
- expect(next.entries[0]?.type).toBe("compaction")
- if (next.entries[0]?.type !== "compaction") return
- expect(next.entries[0].auto).toBe(auto)
- expect(next.entries[0].overflow).toBe(overflow)
- }),
- { numRuns: 50 },
- )
- })
- })
-})
diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts
index 1f66ccb99..651754733 100644
--- a/packages/opencode/test/session/snapshot-tool-race.test.ts
+++ b/packages/opencode/test/session/snapshot-tool-race.test.ts
@@ -33,7 +33,6 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { Command } from "../../src/command"
import { Config } from "../../src/config"
-import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
@@ -102,16 +101,6 @@ const lsp = Layer.succeed(
}),
)
-const filetime = Layer.succeed(
- FileTime.Service,
- FileTime.Service.of({
- read: () => Effect.void,
- get: () => Effect.succeed(undefined),
- assert: () => Effect.void,
- withLock: (_filepath, fn) => fn(),
- }),
-)
-
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
const run = SessionRunState.layer.pipe(Layer.provide(status))
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
@@ -128,7 +117,6 @@ function makeHttp() {
Plugin.defaultLayer,
Config.defaultLayer,
ProviderSvc.defaultLayer,
- filetime,
lsp,
mcp,
AppFileSystem.defaultLayer,
diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
index 2e3dfa8a6..4759b8be3 100644
--- a/packages/opencode/test/tool/edit.test.ts
+++ b/packages/opencode/test/tool/edit.test.ts
@@ -5,7 +5,6 @@ import { Effect, Layer, ManagedRuntime } from "effect"
import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
-import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Format } from "../../src/format"
@@ -38,7 +37,6 @@ async function touch(file: string, time: number) {
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LSP.defaultLayer,
- FileTime.defaultLayer,
AppFileSystem.defaultLayer,
Format.defaultLayer,
Bus.layer,
@@ -59,9 +57,6 @@ const resolve = () =>
}),
)
-const readFileTime = (sessionID: SessionID, filepath: string) =>
- runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
-
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
@@ -173,8 +168,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
const result = await Effect.runPromise(
edit.execute(
@@ -202,8 +195,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
await expect(
Effect.runPromise(
@@ -254,8 +245,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
await expect(
Effect.runPromise(
@@ -273,65 +262,6 @@ describe("tool.edit", () => {
})
})
- test("throws error when file was not read first (FileTime)", async () => {
- await using tmp = await tmpdir()
- const filepath = path.join(tmp.path, "file.txt")
- await fs.writeFile(filepath, "content", "utf-8")
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const edit = await resolve()
- await expect(
- Effect.runPromise(
- edit.execute(
- {
- filePath: filepath,
- oldString: "content",
- newString: "modified",
- },
- ctx,
- ),
- ),
- ).rejects.toThrow("You must read file")
- },
- })
- })
-
- test("throws error when file has been modified since read", async () => {
- await using tmp = await tmpdir()
- const filepath = path.join(tmp.path, "file.txt")
- await fs.writeFile(filepath, "original content", "utf-8")
- await touch(filepath, 1_000)
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- // Read first
- await readFileTime(ctx.sessionID, filepath)
-
- // Simulate external modification
- await fs.writeFile(filepath, "modified externally", "utf-8")
- await touch(filepath, 2_000)
-
- // Try to edit with the new content
- const edit = await resolve()
- await expect(
- Effect.runPromise(
- edit.execute(
- {
- filePath: filepath,
- oldString: "modified externally",
- newString: "edited",
- },
- ctx,
- ),
- ),
- ).rejects.toThrow("modified since it was last read")
- },
- })
- })
-
test("replaces all occurrences with replaceAll option", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
@@ -340,8 +270,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
await Effect.runPromise(
edit.execute(
@@ -369,8 +297,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const { FileWatcher } = await import("../../src/file/watcher")
const updated = await onceBus(FileWatcher.Event.Updated)
@@ -406,8 +332,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
await Effect.runPromise(
edit.execute(
@@ -434,8 +358,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
await Effect.runPromise(
edit.execute(
@@ -487,8 +409,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, dirpath)
-
const edit = await resolve()
await expect(
Effect.runPromise(
@@ -514,8 +434,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
const result = await Effect.runPromise(
edit.execute(
@@ -587,7 +505,6 @@ describe("tool.edit", () => {
fn: async () => {
const edit = await resolve()
const filePath = path.join(tmp.path, "test.txt")
- await readFileTime(ctx.sessionID, filePath)
await Effect.runPromise(
edit.execute(
{
@@ -730,8 +647,6 @@ describe("tool.edit", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
- await readFileTime(ctx.sessionID, filepath)
-
const edit = await resolve()
// Two concurrent edits
@@ -746,9 +661,6 @@ describe("tool.edit", () => {
),
)
- // Need to read again since FileTime tracks per-session
- await readFileTime(ctx.sessionID, filepath)
-
const promise2 = Effect.runPromise(
edit.execute(
{
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 3b32c72e0..42817d15d 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -4,7 +4,6 @@ import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
@@ -16,7 +15,6 @@ import { Tool } from "../../src/tool"
import { Filesystem } from "../../src/util"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
-import { Npm } from "@opencode-ai/shared/npm"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -40,7 +38,6 @@ const it = testEffect(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
- FileTime.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Truncate.defaultLayer,
@@ -397,6 +394,19 @@ describe("tool.read truncation", () => {
}),
)
+ it.live("detects attachment media from file contents", () =>
+ Effect.gen(function* () {
+ const dir = yield* tmpdirScoped()
+ const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
+ yield* put(path.join(dir, "image.bin"), jpeg)
+
+ const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
+ expect(result.output).toBe("Image read successfully")
+ expect(result.attachments?.[0].mime).toBe("image/jpeg")
+ expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
+ }),
+ )
+
it.live("large image files are properly attached without error", () =>
Effect.gen(function* () {
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index 55e126ab4..b12940e4d 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -31,102 +31,6 @@ const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
describe("tool.skill", () => {
- it.live("description lists skill location URL", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- const skill = path.join(dir, ".opencode", "skill", "tool-skill")
- yield* Effect.promise(() =>
- Bun.write(
- path.join(skill, "SKILL.md"),
- `---
-name: tool-skill
-description: Skill for tool tests.
----
-
-# Tool Skill
-`,
- ),
- )
- const home = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = dir
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- process.env.OPENCODE_TEST_HOME = home
- }),
- )
- const registry = yield* ToolRegistry.Service
- const desc =
- (yield* registry.tools({
- providerID: "opencode" as any,
- modelID: "gpt-5" as any,
- agent: { name: "build", mode: "primary", permission: [], options: {} },
- })).find((tool) => tool.id === SkillTool.id)?.description ?? ""
- expect(desc).toContain("**tool-skill**: Skill for tool tests.")
- }),
- { git: true },
- ),
- )
-
- it.live("description sorts skills by name and is stable across calls", () =>
- provideTmpdirInstance(
- (dir) =>
- Effect.gen(function* () {
- for (const [name, description] of [
- ["zeta-skill", "Zeta skill."],
- ["alpha-skill", "Alpha skill."],
- ["middle-skill", "Middle skill."],
- ]) {
- const skill = path.join(dir, ".opencode", "skill", name)
- yield* Effect.promise(() =>
- Bun.write(
- path.join(skill, "SKILL.md"),
- `---
-name: ${name}
-description: ${description}
----
-
-# ${name}
-`,
- ),
- )
- }
- const home = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = dir
- yield* Effect.addFinalizer(() =>
- Effect.sync(() => {
- process.env.OPENCODE_TEST_HOME = home
- }),
- )
-
- const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
- const registry = yield* ToolRegistry.Service
- const load = Effect.fnUntraced(function* () {
- return (
- (yield* registry.tools({
- providerID: "opencode" as any,
- modelID: "gpt-5" as any,
- agent,
- })).find((tool) => tool.id === SkillTool.id)?.description ?? ""
- )
- })
- const first = yield* load()
- const second = yield* load()
-
- expect(first).toBe(second)
-
- const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
- const middle = first.indexOf("**middle-skill**: Middle skill.")
- const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
-
- expect(alpha).toBeGreaterThan(-1)
- expect(middle).toBeGreaterThan(alpha)
- expect(zeta).toBeGreaterThan(middle)
- }),
- { git: true },
- ),
- )
-
it.live("execute returns skill content block with files", () =>
provideTmpdirInstance(
(dir) =>
diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts
index 46bbe2e40..50d3b5752 100644
--- a/packages/opencode/test/tool/write.test.ts
+++ b/packages/opencode/test/tool/write.test.ts
@@ -6,7 +6,6 @@ import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { FileTime } from "../../src/file/time"
import { Bus } from "../../src/bus"
import { Format } from "../../src/format"
import { Truncate } from "../../src/tool"
@@ -36,7 +35,6 @@ const it = testEffect(
Layer.mergeAll(
LSP.defaultLayer,
AppFileSystem.defaultLayer,
- FileTime.defaultLayer,
Bus.layer,
Format.defaultLayer,
CrossSpawnSpawner.defaultLayer,
@@ -58,11 +56,6 @@ const run = Effect.fn("WriteToolTest.run")(function* (
return yield* tool.execute(args, next)
})
-const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
- const ft = yield* FileTime.Service
- yield* ft.read(sessionID as any, filepath)
-})
-
describe("tool.write", () => {
describe("new file creation", () => {
it.live("writes content to new file", () =>
@@ -110,8 +103,6 @@ describe("tool.write", () => {
Effect.gen(function* () {
const filepath = path.join(dir, "existing.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
- yield* markRead(ctx.sessionID, filepath)
-
const result = yield* run({ filePath: filepath, content: "new content" })
expect(result.output).toContain("Wrote file successfully")
@@ -128,8 +119,6 @@ describe("tool.write", () => {
Effect.gen(function* () {
const filepath = path.join(dir, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
- yield* markRead(ctx.sessionID, filepath)
-
const result = yield* run({ filePath: filepath, content: "new" })
expect(result.metadata).toHaveProperty("filepath", filepath)
@@ -231,8 +220,6 @@ describe("tool.write", () => {
const readonlyPath = path.join(dir, "readonly.txt")
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
- yield* markRead(ctx.sessionID, readonlyPath)
-
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts
index 7f7249514..003945b43 100644
--- a/packages/opencode/test/util/effect-zod.test.ts
+++ b/packages/opencode/test/util/effect-zod.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
-import { Schema } from "effect"
+import { Effect, Schema, SchemaGetter } from "effect"
import z from "zod"
-import { zod, ZodOverride } from "../../src/util/effect-zod"
+import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod"
function json(schema: z.ZodTypeAny) {
const { $schema: _, ...rest } = z.toJSONSchema(schema)
@@ -61,8 +61,32 @@ describe("util.effect-zod", () => {
})
})
- test("throws for unsupported tuple schemas", () => {
- expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
+ describe("Tuples", () => {
+ test("fixed-length tuple parses matching array", () => {
+ const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
+ expect(out.parse(["a", 1])).toEqual(["a", 1])
+ expect(out.safeParse(["a"]).success).toBe(false)
+ expect(out.safeParse(["a", "b"]).success).toBe(false)
+ })
+
+ test("single-element tuple parses a one-element array", () => {
+ const out = zod(Schema.Tuple([Schema.Boolean]))
+ expect(out.parse([true])).toEqual([true])
+ expect(out.safeParse([true, false]).success).toBe(false)
+ })
+
+ test("tuple inside a union picks the right branch", () => {
+ const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
+ expect(out.parse("hello")).toBe("hello")
+ expect(out.parse(["foo", 42])).toEqual(["foo", 42])
+ expect(out.safeParse(["foo"]).success).toBe(false)
+ })
+
+ test("plain arrays still work (no element positions)", () => {
+ const out = zod(Schema.Array(Schema.String))
+ expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
+ expect(out.parse([])).toEqual([])
+ })
})
test("string literal unions produce z.enum with enum in JSON Schema", () => {
@@ -186,4 +210,660 @@ describe("util.effect-zod", () => {
const schema = json(zod(Parent)) as any
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
})
+
+ describe("Schema.check translation", () => {
+ test("filter returning string triggers refinement with that message", () => {
+ const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
+ const schema = zod(Schema.Number.check(isEven))
+
+ expect(schema.parse(4)).toBe(4)
+ const result = schema.safeParse(3)
+ expect(result.success).toBe(false)
+ expect(result.error!.issues[0].message).toBe("expected an even number")
+ })
+
+ test("filter returning false triggers refinement with fallback message", () => {
+ const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
+ const schema = zod(Schema.String.check(nonEmpty))
+
+ expect(schema.parse("hi")).toBe("hi")
+ const result = schema.safeParse("")
+ expect(result.success).toBe(false)
+ expect(result.error!.issues[0].message).toMatch(/./)
+ })
+
+ test("filter returning undefined passes validation", () => {
+ const alwaysOk = Schema.makeFilter(() => undefined)
+ const schema = zod(Schema.Number.check(alwaysOk))
+
+ expect(schema.parse(42)).toBe(42)
+ })
+
+ test("annotations.message on the filter is used when filter returns false", () => {
+ const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
+ const schema = zod(Schema.Number.check(positive))
+
+ const result = schema.safeParse(-1)
+ expect(result.success).toBe(false)
+ expect(result.error!.issues[0].message).toBe("must be positive")
+ })
+
+ test("cross-field check on a record flags missing key", () => {
+ const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
+ "required" in data ? undefined : "missing 'required' key",
+ )
+ const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
+
+ expect(schema.parse({ required: { enabled: true } })).toEqual({
+ required: { enabled: true },
+ })
+
+ const result = schema.safeParse({ other: { enabled: true } })
+ expect(result.success).toBe(false)
+ expect(result.error!.issues[0].message).toBe("missing 'required' key")
+ })
+ })
+
+ describe("StructWithRest / catchall", () => {
+ test("struct with a string-keyed record rest parses known AND extra keys", () => {
+ const schema = zod(
+ Schema.StructWithRest(
+ Schema.Struct({
+ apiKey: Schema.optional(Schema.String),
+ baseURL: Schema.optional(Schema.String),
+ }),
+ [Schema.Record(Schema.String, Schema.Unknown)],
+ ),
+ )
+
+ // Known fields come through as declared
+ expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
+
+ // Extra keys are preserved (catchall)
+ expect(
+ schema.parse({
+ apiKey: "sk-x",
+ baseURL: "https://api.example.com",
+ customField: "anything",
+ nested: { foo: 1 },
+ }),
+ ).toEqual({
+ apiKey: "sk-x",
+ baseURL: "https://api.example.com",
+ customField: "anything",
+ nested: { foo: 1 },
+ })
+ })
+
+ test("catchall value type constrains the extras", () => {
+ const schema = zod(
+ Schema.StructWithRest(
+ Schema.Struct({
+ count: Schema.Number,
+ }),
+ [Schema.Record(Schema.String, Schema.Number)],
+ ),
+ )
+
+ // Known field + numeric extras
+ expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
+
+ // Non-numeric extra is rejected
+ expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
+ })
+
+ test("JSON schema output marks additionalProperties appropriately", () => {
+ const schema = zod(
+ Schema.StructWithRest(
+ Schema.Struct({
+ id: Schema.String,
+ }),
+ [Schema.Record(Schema.String, Schema.Unknown)],
+ ),
+ )
+ const shape = json(schema) as { additionalProperties?: unknown }
+ // Presence of `additionalProperties` (truthy or a schema) signals catchall.
+ expect(shape.additionalProperties).not.toBe(false)
+ expect(shape.additionalProperties).toBeDefined()
+ })
+
+ test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
+ const schema = zod(Schema.Struct({ id: Schema.String }))
+ expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
+ })
+ })
+
+ describe("transforms (Schema.decodeTo)", () => {
+ test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
+ // Models the account/account.ts DurationFromSeconds pattern.
+ const SecondsToMs = Schema.Number.pipe(
+ Schema.decodeTo(Schema.Number, {
+ decode: SchemaGetter.transform((n: number) => n * 1000),
+ encode: SchemaGetter.transform((ms: number) => ms / 1000),
+ }),
+ )
+
+ const schema = zod(SecondsToMs)
+ expect(schema.parse(3)).toBe(3000)
+ expect(schema.parse(0)).toBe(0)
+ })
+
+ test("String -> Number via parseInt decode", () => {
+ const ParsedInt = Schema.String.pipe(
+ Schema.decodeTo(Schema.Number, {
+ decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
+ encode: SchemaGetter.transform((n: number) => String(n)),
+ }),
+ )
+
+ const schema = zod(ParsedInt)
+ expect(schema.parse("42")).toBe(42)
+ expect(schema.parse("0")).toBe(0)
+ })
+
+ test("transform inside a struct field applies per-field", () => {
+ const Field = Schema.Number.pipe(
+ Schema.decodeTo(Schema.Number, {
+ decode: SchemaGetter.transform((n: number) => n + 1),
+ encode: SchemaGetter.transform((n: number) => n - 1),
+ }),
+ )
+
+ const schema = zod(
+ Schema.Struct({
+ plain: Schema.Number,
+ bumped: Field,
+ }),
+ )
+
+ expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
+ })
+
+ test("chained decodeTo composes transforms in order", () => {
+ // String -> Number (parseInt) -> Number (doubled).
+ // Exercises the encoded() reduce, not just a single link.
+ const Chained = Schema.String.pipe(
+ Schema.decodeTo(Schema.Number, {
+ decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
+ encode: SchemaGetter.transform((n: number) => String(n)),
+ }),
+ Schema.decodeTo(Schema.Number, {
+ decode: SchemaGetter.transform((n: number) => n * 2),
+ encode: SchemaGetter.transform((n: number) => n / 2),
+ }),
+ )
+
+ const schema = zod(Chained)
+ expect(schema.parse("21")).toBe(42)
+ expect(schema.parse("0")).toBe(0)
+ })
+
+ test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
+ // Schema.Class uses Declaration + encoding under the hood to construct
+ // class instances. The walker must NOT apply that transform, or zod
+ // parsing would return class instances instead of plain objects.
+ class Method extends Schema.Class<Method>("TxTestMethod")({
+ type: Schema.String,
+ value: Schema.Number,
+ }) {}
+
+ const schema = zod(Method)
+ const parsed = schema.parse({ type: "oauth", value: 1 })
+ expect(parsed).toEqual({ type: "oauth", value: 1 })
+ // Guardrail: ensure we didn't get back a Method instance.
+ expect(parsed).not.toBeInstanceOf(Method)
+ })
+ })
+
+ describe("optimizations", () => {
+ test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
+ const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
+ const left = zod(shared)
+ const right = zod(shared)
+ expect(left).toBe(right)
+ })
+
+ test("nested reuse of the same AST reuses the cached Zod child", () => {
+ // Two different parents embed the same inner schema. The inner zod
+ // child should be identical by reference inside both parents.
+ class Inner extends Schema.Class<Inner>("MemoTestInner")({
+ value: Schema.String,
+ }) {}
+
+ class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
+ inner: Inner,
+ }) {}
+
+ class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
+ inner: Inner,
+ }) {}
+
+ const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
+ const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
+ expect(shapeA.inner).toBe(shapeB.inner)
+ })
+
+ test("multiple checks run in a single refinement layer (all fire on one value)", () => {
+ // Three checks attached to the same schema. All three must run and
+ // report — asserting that no check silently got dropped when we
+ // flattened into one superRefine.
+ const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
+ const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
+ const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
+
+ const schema = zod(Schema.Number.check(positive).check(even).check(under100))
+
+ const neg = schema.safeParse(-3)
+ expect(neg.success).toBe(false)
+ expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
+
+ const big = schema.safeParse(101)
+ expect(big.success).toBe(false)
+ expect(big.error!.issues.map((i) => i.message)).toContain("too big")
+
+ // Passing value satisfies all three
+ expect(schema.parse(42)).toBe(42)
+ })
+
+ test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
+ const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
+ const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
+ const group = Schema.makeFilterGroup([positive, even])
+ const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
+
+ const schema = zod(Schema.Number.check(group).check(under100))
+
+ const bad = schema.safeParse(-3)
+ expect(bad.success).toBe(false)
+ expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
+ })
+ })
+
+ describe("well-known refinement translation", () => {
+ test("Schema.isInt emits type: integer in JSON Schema", () => {
+ const schema = zod(Schema.Number.check(Schema.isInt()))
+ const native = json(z.number().int())
+ expect(json(schema)).toEqual(native)
+ expect(schema.parse(3)).toBe(3)
+ expect(schema.safeParse(1.5).success).toBe(false)
+ })
+
+ test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
+ const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
+ expect((json(schema) as any).exclusiveMinimum).toBe(0)
+ expect(schema.parse(1)).toBe(1)
+ expect(schema.safeParse(0).success).toBe(false)
+ expect(schema.safeParse(-1).success).toBe(false)
+ })
+
+ test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
+ const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
+ expect((json(schema) as any).minimum).toBe(0)
+ expect(schema.parse(0)).toBe(0)
+ expect(schema.safeParse(-1).success).toBe(false)
+ })
+
+ test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
+ const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
+ expect((json(schema) as any).exclusiveMaximum).toBe(10)
+ expect(schema.parse(9)).toBe(9)
+ expect(schema.safeParse(10).success).toBe(false)
+ })
+
+ test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
+ const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
+ expect((json(schema) as any).maximum).toBe(10)
+ expect(schema.parse(10)).toBe(10)
+ expect(schema.safeParse(11).success).toBe(false)
+ })
+
+ test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
+ const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
+ expect((json(schema) as any).multipleOf).toBe(5)
+ expect(schema.parse(10)).toBe(10)
+ expect(schema.safeParse(7).success).toBe(false)
+ })
+
+ test("Schema.isFinite validates at runtime", () => {
+ const schema = zod(Schema.Number.check(Schema.isFinite()))
+ expect(schema.parse(1)).toBe(1)
+ expect(schema.safeParse(Infinity).success).toBe(false)
+ expect(schema.safeParse(NaN).success).toBe(false)
+ })
+
+ test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
+ const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
+ const native = json(z.number().int().positive())
+ expect(json(schema)).toEqual(native)
+ expect(schema.parse(3)).toBe(3)
+ expect(schema.safeParse(0).success).toBe(false)
+ expect(schema.safeParse(1.5).success).toBe(false)
+ })
+
+ test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
+ const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
+ const native = json(z.number().int().min(0))
+ expect(json(schema)).toEqual(native)
+ expect(schema.parse(0)).toBe(0)
+ expect(schema.safeParse(-1).success).toBe(false)
+ })
+
+ test("Schema.isBetween emits both bounds", () => {
+ const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
+ const shape = json(schema) as any
+ expect(shape.minimum).toBe(1)
+ expect(shape.maximum).toBe(10)
+ expect(schema.parse(5)).toBe(5)
+ expect(schema.safeParse(11).success).toBe(false)
+ expect(schema.safeParse(0).success).toBe(false)
+ })
+
+ test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
+ const schema = zod(
+ Schema.Number.check(
+ Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
+ ),
+ )
+ const shape = json(schema) as any
+ expect(shape.exclusiveMinimum).toBe(1)
+ expect(shape.exclusiveMaximum).toBe(10)
+ expect(schema.parse(5)).toBe(5)
+ expect(schema.safeParse(1).success).toBe(false)
+ expect(schema.safeParse(10).success).toBe(false)
+ })
+
+ test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
+ const schema = zod(Schema.Number.check(Schema.isInt32()))
+ const shape = json(schema) as any
+ expect(shape.type).toBe("integer")
+ expect(shape.minimum).toBe(-2147483648)
+ expect(shape.maximum).toBe(2147483647)
+ expect(schema.parse(42)).toBe(42)
+ expect(schema.safeParse(1.5).success).toBe(false)
+ expect(schema.safeParse(2147483648).success).toBe(false)
+ })
+
+ test("Schema.isMinLength on string emits minLength", () => {
+ const schema = zod(Schema.String.check(Schema.isMinLength(3)))
+ expect((json(schema) as any).minLength).toBe(3)
+ expect(schema.parse("abc")).toBe("abc")
+ expect(schema.safeParse("ab").success).toBe(false)
+ })
+
+ test("Schema.isMaxLength on string emits maxLength", () => {
+ const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
+ expect((json(schema) as any).maxLength).toBe(5)
+ expect(schema.parse("abcde")).toBe("abcde")
+ expect(schema.safeParse("abcdef").success).toBe(false)
+ })
+
+ test("Schema.isLengthBetween on string emits both bounds", () => {
+ const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
+ const shape = json(schema) as any
+ expect(shape.minLength).toBe(2)
+ expect(shape.maxLength).toBe(4)
+ expect(schema.parse("abc")).toBe("abc")
+ expect(schema.safeParse("a").success).toBe(false)
+ expect(schema.safeParse("abcde").success).toBe(false)
+ })
+
+ test("Schema.isMinLength on array emits minItems", () => {
+ const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
+ expect((json(schema) as any).minItems).toBe(1)
+ expect(schema.parse(["x"])).toEqual(["x"])
+ expect(schema.safeParse([]).success).toBe(false)
+ })
+
+ test("Schema.isPattern emits pattern", () => {
+ const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
+ expect((json(schema) as any).pattern).toBe("^per")
+ expect(schema.parse("per_abc")).toBe("per_abc")
+ expect(schema.safeParse("abc").success).toBe(false)
+ })
+
+ test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
+ const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
+ const native = json(z.string().startsWith("per"))
+ expect(json(schema)).toEqual(native)
+ expect(schema.parse("per_abc")).toBe("per_abc")
+ expect(schema.safeParse("abc").success).toBe(false)
+ })
+
+ test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
+ const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
+ const native = json(z.string().endsWith(".json"))
+ expect(json(schema)).toEqual(native)
+ expect(schema.parse("a.json")).toBe("a.json")
+ expect(schema.safeParse("a.txt").success).toBe(false)
+ })
+
+ test("Schema.isUUID emits format: uuid", () => {
+ const schema = zod(Schema.String.check(Schema.isUUID()))
+ expect((json(schema) as any).format).toBe("uuid")
+ })
+
+ test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
+ // isInt is well-known (translates to .int()); the anonymous filter falls
+ // back to superRefine.
+ const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
+ const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
+
+ const shape = json(schema) as any
+ // Well-known translation is preserved — type is integer, not plain number
+ expect(shape.type).toBe("integer")
+
+ // Runtime: both constraints fire
+ expect(schema.parse(3)).toBe(3)
+ expect(schema.safeParse(1.5).success).toBe(false)
+ const seven = schema.safeParse(7)
+ expect(seven.success).toBe(false)
+ expect(seven.error!.issues[0].message).toBe("no sevens allowed")
+ })
+
+ test("inside a struct field, well-known refinements propagate through", () => {
+ // Mirrors config.ts port: z.number().int().positive().optional()
+ const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
+ const schema = zod(Schema.Struct({ port: Port }))
+ const shape = json(schema) as any
+ expect(shape.properties.port.type).toBe("integer")
+ expect(shape.properties.port.exclusiveMinimum).toBe(0)
+ })
+ })
+
+ describe("Schema.optionalWith defaults", () => {
+ test("parsing undefined returns the default value", () => {
+ const schema = zod(
+ Schema.Struct({
+ mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ }),
+ )
+ expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
+ expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
+ })
+
+ test("parsing a real value returns that value (default does not fire)", () => {
+ const schema = zod(
+ Schema.Struct({
+ mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ }),
+ )
+ expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
+ })
+
+ test("default on a number field", () => {
+ const schema = zod(
+ Schema.Struct({
+ count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
+ }),
+ )
+ expect(schema.parse({})).toEqual({ count: 42 })
+ expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
+ })
+
+ test("multiple defaulted fields inside a struct", () => {
+ const schema = zod(
+ Schema.Struct({
+ leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
+ inner: Schema.String,
+ }),
+ )
+ expect(schema.parse({ inner: "hi" })).toEqual({
+ leader: "ctrl-x",
+ quit: "ctrl-c",
+ inner: "hi",
+ })
+ expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
+ leader: "a",
+ quit: "b",
+ inner: "c",
+ })
+ })
+
+ test("JSON Schema output includes the default key", () => {
+ const schema = zod(
+ Schema.Struct({
+ mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
+ }),
+ )
+ const shape = json(schema) as any
+ expect(shape.properties.mode.default).toBe("ctrl-x")
+ })
+
+ test("default referencing a computed value resolves when evaluated", () => {
+ // Simulates `keybinds.ts` style of per-platform defaults: the default is
+ // produced by an Effect that computes a value at decode time.
+ const platform = "darwin"
+ const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
+ const schema = zod(
+ Schema.Struct({
+ command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))),
+ }),
+ )
+ expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
+ const shape = json(schema) as any
+ expect(shape.properties.command_palette.default).toBe("cmd-k")
+ })
+
+ test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
+ const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
+ expect(schema.parse({})).toEqual({})
+ expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
+ })
+ })
+
+ describe("ZodPreprocess annotation", () => {
+ test("preprocess runs on raw input before the inner schema parses", () => {
+ // Models the permission.ts __originalKeys pattern: capture the original
+ // insertion order of a user-provided object BEFORE Schema parsing
+ // canonicalises the keys.
+ const preprocess = (val: unknown) => {
+ if (typeof val === "object" && val !== null && !Array.isArray(val)) {
+ return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
+ }
+ return val
+ }
+ const Inner = Schema.Struct({
+ __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ a: Schema.optional(Schema.String),
+ b: Schema.optional(Schema.String),
+ }).annotate({ [ZodPreprocess]: preprocess })
+
+ const schema = zod(Inner)
+ const parsed = schema.parse({ b: "1", a: "2" }) as {
+ __keys?: string[]
+ a?: string
+ b?: string
+ }
+ expect(parsed.__keys).toEqual(["b", "a"])
+ expect(parsed.a).toBe("2")
+ expect(parsed.b).toBe("1")
+ })
+
+ test("preprocess does not transform already-shaped input", () => {
+ // When the user passes an object that already has __keys, preprocess
+ // returns it unchanged because spreading preserves any existing key.
+ const preprocess = (val: unknown) => {
+ if (typeof val === "object" && val !== null && !("__keys" in val)) {
+ return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
+ }
+ return val
+ }
+ const Inner = Schema.Struct({
+ __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ a: Schema.optional(Schema.String),
+ }).annotate({ [ZodPreprocess]: preprocess })
+
+ const schema = zod(Inner)
+ const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as {
+ __keys?: string[]
+ a?: string
+ }
+ expect(parsed.__keys).toEqual(["existing"])
+ })
+
+ test("preprocess composes with a union (either object or string)", () => {
+ // Mirrors permission.ts exactly: input can be either an object (with
+ // preprocess injecting metadata) or a plain string action.
+ const Action = Schema.Literals(["ask", "allow", "deny"])
+ const Obj = Schema.Struct({
+ __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
+ read: Schema.optional(Action),
+ write: Schema.optional(Action),
+ })
+ const preprocess = (val: unknown) => {
+ if (typeof val === "object" && val !== null && !Array.isArray(val)) {
+ return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
+ }
+ return val
+ }
+ const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess })
+ const schema = zod(Inner)
+
+ // String branch — passes through preprocess unchanged
+ expect(schema.parse("allow")).toBe("allow")
+
+ // Object branch — __keys injected, preserves order
+ const parsed = schema.parse({ write: "allow", read: "deny" }) as {
+ __keys?: string[]
+ read?: string
+ write?: string
+ }
+ expect(parsed.__keys).toEqual(["write", "read"])
+ expect(parsed.write).toBe("allow")
+ expect(parsed.read).toBe("deny")
+ })
+
+ test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => {
+ const Inner = Schema.Struct({
+ a: Schema.optional(Schema.String),
+ b: Schema.optional(Schema.Number),
+ }).annotate({ [ZodPreprocess]: (v: unknown) => v })
+ const shape = json(zod(Inner)) as any
+ expect(shape.type).toBe("object")
+ expect(shape.properties.a.type).toBe("string")
+ expect(shape.properties.b.type).toBe("number")
+ })
+
+ test("identifier + description propagate through the preprocess wrapper", () => {
+ const Inner = Schema.Struct({
+ x: Schema.optional(Schema.String),
+ }).annotate({
+ identifier: "WithPreproc",
+ description: "A schema with preprocess",
+ [ZodPreprocess]: (v: unknown) => v,
+ })
+ const schema = zod(Inner)
+ expect(schema.meta()?.ref).toBe("WithPreproc")
+ expect(schema.meta()?.description).toBe("A schema with preprocess")
+ })
+
+ test("preprocess inside a struct field applies only to that field", () => {
+ const Inner = Schema.String.annotate({
+ [ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v),
+ })
+ const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number }))
+ expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 })
+ })
+ })
})
diff --git a/packages/opencode/test/workspace/workspace-restore.test.ts b/packages/opencode/test/workspace/workspace-restore.test.ts
index 429eeaf9d..ad6ac2c5f 100644
--- a/packages/opencode/test/workspace/workspace-restore.test.ts
+++ b/packages/opencode/test/workspace/workspace-restore.test.ts
@@ -141,9 +141,12 @@ describe("Workspace.sessionRestore", () => {
Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
- if (url.pathname !== "/base/sync/replay") {
+ if (url.pathname === "/base/global/event") {
return eventStreamResponse()
}
+ if (url.pathname === "/base/sync/history") {
+ return Response.json([])
+ }
const body = JSON.parse(String(init?.body))
posts.push({
path: url.pathname,
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 6f9a0ea1d..c73addc47 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"scripts": {
@@ -22,8 +22,8 @@
"zod": "catalog:"
},
"peerDependencies": {
- "@opentui/core": ">=0.1.99",
- "@opentui/solid": ">=0.1.99"
+ "@opentui/core": ">=0.1.101",
+ "@opentui/solid": ">=0.1.101"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -34,8 +34,8 @@
}
},
"devDependencies": {
- "@opentui/core": "0.1.99",
- "@opentui/solid": "0.1.99",
+ "@opentui/core": "0.1.101",
+ "@opentui/solid": "0.1.101",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts
index d2fe49f23..fea8c7230 100755
--- a/packages/plugin/script/publish.ts
+++ b/packages/plugin/script/publish.ts
@@ -6,17 +6,33 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
+async function published(name: string, version: string) {
+ return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
+}
+
await $`bun tsc`
-const pkg = await import("../package.json").then((m) => m.default)
-const original = JSON.parse(JSON.stringify(pkg))
-for (const [key, value] of Object.entries(pkg.exports)) {
- const file = value.replace("./src/", "./dist/").replace(".ts", "")
- // @ts-ignore
- pkg.exports[key] = {
- import: file + ".js",
- types: file + ".d.ts",
+const originalText = await Bun.file("package.json").text()
+const pkg = JSON.parse(originalText) as {
+ name: string
+ version: string
+ exports: Record<string, string>
+}
+if (await published(pkg.name, pkg.version)) {
+ console.log(`already published ${pkg.name}@${pkg.version}`)
+} else {
+ for (const [key, value] of Object.entries(pkg.exports)) {
+ const file = value.replace("./src/", "./dist/").replace(".ts", "")
+ // @ts-ignore
+ pkg.exports[key] = {
+ import: file + ".js",
+ types: file + ".d.ts",
+ }
+ }
+ await Bun.write("package.json", JSON.stringify(pkg, null, 2))
+ try {
+ await $`bun pm pack`
+ await $`npm publish *.tgz --tag ${Script.channel} --access public`
+ } finally {
+ await Bun.write("package.json", originalText)
}
}
-await Bun.write("package.json", JSON.stringify(pkg, null, 2))
-await $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public`
-await Bun.write("package.json", JSON.stringify(original, null, 2))
diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts
index 099cf2758..1c57a71ab 100644
--- a/packages/plugin/src/tui.ts
+++ b/packages/plugin/src/tui.ts
@@ -29,7 +29,7 @@ export type TuiRouteCurrent =
name: "session"
params: {
sessionID: string
- initialPrompt?: unknown
+ prompt?: unknown
}
}
| {
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 53a589314..f39b575c8 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/sdk/js/script/publish.ts b/packages/sdk/js/script/publish.ts
index ea5c5d634..29426a41b 100755
--- a/packages/sdk/js/script/publish.ts
+++ b/packages/sdk/js/script/publish.ts
@@ -7,25 +7,39 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
-const pkg = (await import("../package.json").then((m) => m.default)) as {
- exports: Record<string, string | object>
+async function published(name: string, version: string) {
+ return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
-const original = JSON.parse(JSON.stringify(pkg))
-function transformExports(exports: Record<string, string | object>) {
- for (const [key, value] of Object.entries(exports)) {
- if (typeof value === "object" && value !== null) {
- transformExports(value as Record<string, string | object>)
- } else if (typeof value === "string") {
- const file = value.replace("./src/", "./dist/").replace(".ts", "")
- exports[key] = {
- import: file + ".js",
- types: file + ".d.ts",
+
+const originalText = await Bun.file("package.json").text()
+const pkg = JSON.parse(originalText) as {
+ name: string
+ version: string
+ exports: Record<string, unknown>
+}
+function transformExports(exports: Record<string, unknown>) {
+ return Object.fromEntries(
+ Object.entries(exports).map(([key, value]) => {
+ if (typeof value === "string") {
+ const file = value.replace("./src/", "./dist/").replace(".ts", "")
+ return [key, { import: file + ".js", types: file + ".d.ts" }]
}
- }
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ return [key, transformExports(value)]
+ }
+ return [key, value]
+ }),
+ )
+}
+if (await published(pkg.name, pkg.version)) {
+ console.log(`already published ${pkg.name}@${pkg.version}`)
+} else {
+ pkg.exports = transformExports(pkg.exports)
+ await Bun.write("package.json", JSON.stringify(pkg, null, 2))
+ try {
+ await $`bun pm pack`
+ await $`npm publish *.tgz --tag ${Script.channel} --access public`
+ } finally {
+ await Bun.write("package.json", originalText)
}
}
-transformExports(pkg.exports)
-await Bun.write("package.json", JSON.stringify(pkg, null, 2))
-await $`bun pm pack`
-await $`npm publish *.tgz --tag ${Script.channel} --access public`
-await Bun.write("package.json", JSON.stringify(original, null, 2))
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index d7bf43f50..6248eb8e4 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -163,6 +163,7 @@ import type {
SyncHistoryListResponses,
SyncReplayErrors,
SyncReplayResponses,
+ SyncStartResponses,
TextPartInput,
ToolIdsErrors,
ToolIdsResponses,
@@ -510,11 +511,11 @@ export class App extends HeyApiClient {
}
}
-export class Project extends HeyApiClient {
+export class Adaptor extends HeyApiClient {
/**
- * List all projects
+ * List workspace adaptors
*
- * Get a list of projects that have been opened with OpenCode.
+ * List all available workspace adaptors for the current project.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
@@ -534,19 +535,21 @@ export class Project extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<ProjectListResponses, unknown, ThrowOnError>({
- url: "/project",
+ return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
+ url: "/experimental/workspace/adaptor",
...options,
...params,
})
}
+}
+export class Workspace extends HeyApiClient {
/**
- * Get current project
+ * List workspaces
*
- * Retrieve the currently active project that OpenCode is working with.
+ * List all workspaces.
*/
- public current<ThrowOnError extends boolean = false>(
+ public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
@@ -564,22 +567,26 @@ export class Project extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<ProjectCurrentResponses, unknown, ThrowOnError>({
- url: "/project/current",
+ return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({
+ url: "/experimental/workspace",
...options,
...params,
})
}
/**
- * Initialize git repository
+ * Create workspace
*
- * Create a git repository for the current project and return the refreshed project info.
+ * Create a workspace for the current project.
*/
- public initGit<ThrowOnError extends boolean = false>(
+ public create<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
+ id?: string
+ type?: string
+ branch?: string | null
+ extra?: unknown | null
},
options?: Options<never, ThrowOnError>,
) {
@@ -590,39 +597,39 @@ export class Project extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "id" },
+ { in: "body", key: "type" },
+ { in: "body", key: "branch" },
+ { in: "body", key: "extra" },
],
},
],
)
- return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
- url: "/project/git/init",
+ return (options?.client ?? this.client).post<
+ ExperimentalWorkspaceCreateResponses,
+ ExperimentalWorkspaceCreateErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/workspace",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
/**
- * Update project
+ * Workspace status
*
- * Update project properties such as name, icon, and commands.
+ * Get connection status for workspaces in the current project.
*/
- public update<ThrowOnError extends boolean = false>(
- parameters: {
- projectID: string
+ public status<ThrowOnError extends boolean = false>(
+ parameters?: {
directory?: string
workspace?: string
- name?: string
- icon?: {
- url?: string
- override?: string
- color?: string
- }
- commands?: {
- /**
- * Startup script to run when creating a new workspace (worktree)
- */
- start?: string
- }
},
options?: Options<never, ThrowOnError>,
) {
@@ -631,37 +638,27 @@ export class Project extends HeyApiClient {
[
{
args: [
- { in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "name" },
- { in: "body", key: "icon" },
- { in: "body", key: "commands" },
],
},
],
)
- return (options?.client ?? this.client).patch<ProjectUpdateResponses, ProjectUpdateErrors, ThrowOnError>({
- url: "/project/{projectID}",
+ return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
+ url: "/experimental/workspace/status",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
-}
-export class Pty extends HeyApiClient {
/**
- * List PTY sessions
+ * Remove workspace
*
- * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.
+ * Remove an existing workspace.
*/
- public list<ThrowOnError extends boolean = false>(
- parameters?: {
+ public remove<ThrowOnError extends boolean = false>(
+ parameters: {
+ id: string
directory?: string
workspace?: string
},
@@ -672,35 +669,35 @@ export class Pty extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
- return (options?.client ?? this.client).get<PtyListResponses, unknown, ThrowOnError>({
- url: "/pty",
+ return (options?.client ?? this.client).delete<
+ ExperimentalWorkspaceRemoveResponses,
+ ExperimentalWorkspaceRemoveErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/workspace/{id}",
...options,
...params,
})
}
/**
- * Create PTY session
+ * Restore session into workspace
*
- * Create a new pseudo-terminal (PTY) session for running shell commands and processes.
+ * Replay a session's sync events into the target workspace in batches.
*/
- public create<ThrowOnError extends boolean = false>(
- parameters?: {
+ public sessionRestore<ThrowOnError extends boolean = false>(
+ parameters: {
+ id: string
directory?: string
workspace?: string
- command?: string
- args?: Array<string>
- cwd?: string
- title?: string
- env?: {
- [key: string]: string
- }
+ sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -709,19 +706,20 @@ export class Pty extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "command" },
- { in: "body", key: "args" },
- { in: "body", key: "cwd" },
- { in: "body", key: "title" },
- { in: "body", key: "env" },
+ { in: "body", key: "sessionID" },
],
},
],
)
- return (options?.client ?? this.client).post<PtyCreateResponses, PtyCreateErrors, ThrowOnError>({
- url: "/pty",
+ return (options?.client ?? this.client).post<
+ ExperimentalWorkspaceSessionRestoreResponses,
+ ExperimentalWorkspaceSessionRestoreErrors,
+ ThrowOnError
+ >({
+ url: "/experimental/workspace/{id}/session-restore",
...options,
...params,
headers: {
@@ -732,14 +730,20 @@ export class Pty extends HeyApiClient {
})
}
+ private _adaptor?: Adaptor
+ get adaptor(): Adaptor {
+ return (this._adaptor ??= new Adaptor({ client: this.client }))
+ }
+}
+
+export class Console extends HeyApiClient {
/**
- * Remove PTY session
+ * Get active Console provider metadata
*
- * Remove and terminate a specific pseudo-terminal (PTY) session.
+ * Get the active Console org name and the set of provider IDs managed by that Console org.
*/
- public remove<ThrowOnError extends boolean = false>(
- parameters: {
- ptyID: string
+ public get<ThrowOnError extends boolean = false>(
+ parameters?: {
directory?: string
workspace?: string
},
@@ -750,28 +754,26 @@ export class Pty extends HeyApiClient {
[
{
args: [
- { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
- return (options?.client ?? this.client).delete<PtyRemoveResponses, PtyRemoveErrors, ThrowOnError>({
- url: "/pty/{ptyID}",
+ return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
+ url: "/experimental/console",
...options,
...params,
})
}
/**
- * Get PTY session
+ * List switchable Console orgs
*
- * Retrieve detailed information about a specific pseudo-terminal (PTY) session.
+ * Get the available Console orgs across logged-in accounts, including the current active org.
*/
- public get<ThrowOnError extends boolean = false>(
- parameters: {
- ptyID: string
+ public listOrgs<ThrowOnError extends boolean = false>(
+ parameters?: {
directory?: string
workspace?: string
},
@@ -782,35 +784,30 @@ export class Pty extends HeyApiClient {
[
{
args: [
- { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
- return (options?.client ?? this.client).get<PtyGetResponses, PtyGetErrors, ThrowOnError>({
- url: "/pty/{ptyID}",
+ return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
+ url: "/experimental/console/orgs",
...options,
...params,
})
}
/**
- * Update PTY session
+ * Switch active Console org
*
- * Update properties of an existing pseudo-terminal (PTY) session.
+ * Persist a new active Console account/org selection for the current local OpenCode state.
*/
- public update<ThrowOnError extends boolean = false>(
- parameters: {
- ptyID: string
+ public switchOrg<ThrowOnError extends boolean = false>(
+ parameters?: {
directory?: string
workspace?: string
- title?: string
- size?: {
- rows: number
- cols: number
- }
+ accountID?: string
+ orgID?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -819,17 +816,16 @@ export class Pty extends HeyApiClient {
[
{
args: [
- { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "title" },
- { in: "body", key: "size" },
+ { in: "body", key: "accountID" },
+ { in: "body", key: "orgID" },
],
},
],
)
- return (options?.client ?? this.client).put<PtyUpdateResponses, PtyUpdateErrors, ThrowOnError>({
- url: "/pty/{ptyID}",
+ return (options?.client ?? this.client).post<ExperimentalConsoleSwitchOrgResponses, unknown, ThrowOnError>({
+ url: "/experimental/console/switch",
...options,
...params,
headers: {
@@ -839,17 +835,24 @@ export class Pty extends HeyApiClient {
},
})
}
+}
+export class Session extends HeyApiClient {
/**
- * Connect to PTY session
+ * List sessions
*
- * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.
+ * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.
*/
- public connect<ThrowOnError extends boolean = false>(
- parameters: {
- ptyID: string
+ public list<ThrowOnError extends boolean = false>(
+ parameters?: {
directory?: string
workspace?: string
+ roots?: boolean
+ start?: number
+ cursor?: number
+ search?: string
+ limit?: number
+ archived?: boolean
},
options?: Options<never, ThrowOnError>,
) {
@@ -858,28 +861,33 @@ export class Pty extends HeyApiClient {
[
{
args: [
- { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "query", key: "roots" },
+ { in: "query", key: "start" },
+ { in: "query", key: "cursor" },
+ { in: "query", key: "search" },
+ { in: "query", key: "limit" },
+ { in: "query", key: "archived" },
],
},
],
)
- return (options?.client ?? this.client).get<PtyConnectResponses, PtyConnectErrors, ThrowOnError>({
- url: "/pty/{ptyID}/connect",
+ return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({
+ url: "/experimental/session",
...options,
...params,
})
}
}
-export class Config2 extends HeyApiClient {
+export class Resource extends HeyApiClient {
/**
- * Get configuration
+ * Get MCP resources
*
- * Retrieve the current OpenCode configuration settings and preferences.
+ * Get all available MCP resources from connected servers. Optionally filter by name.
*/
- public get<ThrowOnError extends boolean = false>(
+ public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
@@ -897,23 +905,46 @@ export class Config2 extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<ConfigGetResponses, unknown, ThrowOnError>({
- url: "/config",
+ return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
+ url: "/experimental/resource",
...options,
...params,
})
}
+}
+
+export class Experimental extends HeyApiClient {
+ private _workspace?: Workspace
+ get workspace(): Workspace {
+ return (this._workspace ??= new Workspace({ client: this.client }))
+ }
+
+ private _console?: Console
+ get console(): Console {
+ return (this._console ??= new Console({ client: this.client }))
+ }
+ private _session?: Session
+ get session(): Session {
+ return (this._session ??= new Session({ client: this.client }))
+ }
+
+ private _resource?: Resource
+ get resource(): Resource {
+ return (this._resource ??= new Resource({ client: this.client }))
+ }
+}
+
+export class Project extends HeyApiClient {
/**
- * Update configuration
+ * List all projects
*
- * Update OpenCode configuration settings and preferences.
+ * Get a list of projects that have been opened with OpenCode.
*/
- public update<ThrowOnError extends boolean = false>(
+ public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
- config?: Config3
},
options?: Options<never, ThrowOnError>,
) {
@@ -924,29 +955,23 @@ export class Config2 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { key: "config", map: "body" },
],
},
],
)
- return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
- url: "/config",
+ return (options?.client ?? this.client).get<ProjectListResponses, unknown, ThrowOnError>({
+ url: "/project",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
/**
- * List config providers
+ * Get current project
*
- * Get a list of all configured AI providers and their default models.
+ * Retrieve the currently active project that OpenCode is working with.
*/
- public providers<ThrowOnError extends boolean = false>(
+ public current<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
@@ -964,21 +989,19 @@ export class Config2 extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<ConfigProvidersResponses, unknown, ThrowOnError>({
- url: "/config/providers",
+ return (options?.client ?? this.client).get<ProjectCurrentResponses, unknown, ThrowOnError>({
+ url: "/project/current",
...options,
...params,
})
}
-}
-export class Console extends HeyApiClient {
/**
- * Get active Console provider metadata
+ * Initialize git repository
*
- * Get the active Console org name and the set of provider IDs managed by that Console org.
+ * Create a git repository for the current project and return the refreshed project info.
*/
- public get<ThrowOnError extends boolean = false>(
+ public initGit<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
@@ -996,22 +1019,35 @@ export class Console extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
- url: "/experimental/console",
+ return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
+ url: "/project/git/init",
...options,
...params,
})
}
/**
- * List switchable Console orgs
+ * Update project
*
- * Get the available Console orgs across logged-in accounts, including the current active org.
+ * Update project properties such as name, icon, and commands.
*/
- public listOrgs<ThrowOnError extends boolean = false>(
- parameters?: {
+ public update<ThrowOnError extends boolean = false>(
+ parameters: {
+ projectID: string
directory?: string
workspace?: string
+ name?: string
+ icon?: {
+ url?: string
+ override?: string
+ color?: string
+ }
+ commands?: {
+ /**
+ * Startup script to run when creating a new workspace (worktree)
+ */
+ start?: string
+ }
},
options?: Options<never, ThrowOnError>,
) {
@@ -1020,30 +1056,39 @@ export class Console extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "projectID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "name" },
+ { in: "body", key: "icon" },
+ { in: "body", key: "commands" },
],
},
],
)
- return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
- url: "/experimental/console/orgs",
+ return (options?.client ?? this.client).patch<ProjectUpdateResponses, ProjectUpdateErrors, ThrowOnError>({
+ url: "/project/{projectID}",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
+}
+export class Pty extends HeyApiClient {
/**
- * Switch active Console org
+ * List PTY sessions
*
- * Persist a new active Console account/org selection for the current local OpenCode state.
+ * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.
*/
- public switchOrg<ThrowOnError extends boolean = false>(
+ public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
- accountID?: string
- orgID?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -1054,35 +1099,33 @@ export class Console extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "accountID" },
- { in: "body", key: "orgID" },
],
},
],
)
- return (options?.client ?? this.client).post<ExperimentalConsoleSwitchOrgResponses, unknown, ThrowOnError>({
- url: "/experimental/console/switch",
+ return (options?.client ?? this.client).get<PtyListResponses, unknown, ThrowOnError>({
+ url: "/pty",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
-}
-export class Adaptor extends HeyApiClient {
/**
- * List workspace adaptors
+ * Create PTY session
*
- * List all available workspace adaptors for the current project.
+ * Create a new pseudo-terminal (PTY) session for running shell commands and processes.
*/
- public list<ThrowOnError extends boolean = false>(
+ public create<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
+ command?: string
+ args?: Array<string>
+ cwd?: string
+ title?: string
+ env?: {
+ [key: string]: string
+ }
},
options?: Options<never, ThrowOnError>,
) {
@@ -1093,26 +1136,35 @@ export class Adaptor extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "command" },
+ { in: "body", key: "args" },
+ { in: "body", key: "cwd" },
+ { in: "body", key: "title" },
+ { in: "body", key: "env" },
],
},
],
)
- return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
- url: "/experimental/workspace/adaptor",
+ return (options?.client ?? this.client).post<PtyCreateResponses, PtyCreateErrors, ThrowOnError>({
+ url: "/pty",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
-}
-export class Workspace extends HeyApiClient {
/**
- * List workspaces
+ * Remove PTY session
*
- * List all workspaces.
+ * Remove and terminate a specific pseudo-terminal (PTY) session.
*/
- public list<ThrowOnError extends boolean = false>(
- parameters?: {
+ public remove<ThrowOnError extends boolean = false>(
+ parameters: {
+ ptyID: string
directory?: string
workspace?: string
},
@@ -1123,32 +1175,30 @@ export class Workspace extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
- return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({
- url: "/experimental/workspace",
+ return (options?.client ?? this.client).delete<PtyRemoveResponses, PtyRemoveErrors, ThrowOnError>({
+ url: "/pty/{ptyID}",
...options,
...params,
})
}
/**
- * Create workspace
+ * Get PTY session
*
- * Create a workspace for the current project.
+ * Retrieve detailed information about a specific pseudo-terminal (PTY) session.
*/
- public create<ThrowOnError extends boolean = false>(
- parameters?: {
+ public get<ThrowOnError extends boolean = false>(
+ parameters: {
+ ptyID: string
directory?: string
workspace?: string
- id?: string
- type?: string
- branch?: string | null
- extra?: unknown | null
},
options?: Options<never, ThrowOnError>,
) {
@@ -1157,41 +1207,35 @@ export class Workspace extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "id" },
- { in: "body", key: "type" },
- { in: "body", key: "branch" },
- { in: "body", key: "extra" },
],
},
],
)
- return (options?.client ?? this.client).post<
- ExperimentalWorkspaceCreateResponses,
- ExperimentalWorkspaceCreateErrors,
- ThrowOnError
- >({
- url: "/experimental/workspace",
+ return (options?.client ?? this.client).get<PtyGetResponses, PtyGetErrors, ThrowOnError>({
+ url: "/pty/{ptyID}",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
/**
- * Workspace status
+ * Update PTY session
*
- * Get connection status for workspaces in the current project.
+ * Update properties of an existing pseudo-terminal (PTY) session.
*/
- public status<ThrowOnError extends boolean = false>(
- parameters?: {
+ public update<ThrowOnError extends boolean = false>(
+ parameters: {
+ ptyID: string
directory?: string
workspace?: string
+ title?: string
+ size?: {
+ rows: number
+ cols: number
+ }
},
options?: Options<never, ThrowOnError>,
) {
@@ -1200,27 +1244,35 @@ export class Workspace extends HeyApiClient {
[
{
args: [
+ { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
+ { in: "body", key: "title" },
+ { in: "body", key: "size" },
],
},
],
)
- return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
- url: "/experimental/workspace/status",
+ return (options?.client ?? this.client).put<PtyUpdateResponses, PtyUpdateErrors, ThrowOnError>({
+ url: "/pty/{ptyID}",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
/**
- * Remove workspace
+ * Connect to PTY session
*
- * Remove an existing workspace.
+ * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.
*/
- public remove<ThrowOnError extends boolean = false>(
+ public connect<ThrowOnError extends boolean = false>(
parameters: {
- id: string
+ ptyID: string
directory?: string
workspace?: string
},
@@ -1231,35 +1283,31 @@ export class Workspace extends HeyApiClient {
[
{
args: [
- { in: "path", key: "id" },
+ { in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
- return (options?.client ?? this.client).delete<
- ExperimentalWorkspaceRemoveResponses,
- ExperimentalWorkspaceRemoveErrors,
- ThrowOnError
- >({
- url: "/experimental/workspace/{id}",
+ return (options?.client ?? this.client).get<PtyConnectResponses, PtyConnectErrors, ThrowOnError>({
+ url: "/pty/{ptyID}/connect",
...options,
...params,
})
}
+}
+export class Config2 extends HeyApiClient {
/**
- * Restore session into workspace
+ * Get configuration
*
- * Replay a session's sync events into the target workspace in batches.
+ * Retrieve the current OpenCode configuration settings and preferences.
*/
- public sessionRestore<ThrowOnError extends boolean = false>(
- parameters: {
- id: string
+ public get<ThrowOnError extends boolean = false>(
+ parameters?: {
directory?: string
workspace?: string
- sessionID?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -1268,52 +1316,29 @@ export class Workspace extends HeyApiClient {
[
{
args: [
- { in: "path", key: "id" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "sessionID" },
],
},
],
)
- return (options?.client ?? this.client).post<
- ExperimentalWorkspaceSessionRestoreResponses,
- ExperimentalWorkspaceSessionRestoreErrors,
- ThrowOnError
- >({
- url: "/experimental/workspace/{id}/session-restore",
+ return (options?.client ?? this.client).get<ConfigGetResponses, unknown, ThrowOnError>({
+ url: "/config",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
- private _adaptor?: Adaptor
- get adaptor(): Adaptor {
- return (this._adaptor ??= new Adaptor({ client: this.client }))
- }
-}
-
-export class Session extends HeyApiClient {
/**
- * List sessions
+ * Update configuration
*
- * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.
+ * Update OpenCode configuration settings and preferences.
*/
- public list<ThrowOnError extends boolean = false>(
+ public update<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
- roots?: boolean
- start?: number
- cursor?: number
- search?: string
- limit?: number
- archived?: boolean
+ config?: Config3
},
options?: Options<never, ThrowOnError>,
) {
@@ -1324,31 +1349,29 @@ export class Session extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "query", key: "roots" },
- { in: "query", key: "start" },
- { in: "query", key: "cursor" },
- { in: "query", key: "search" },
- { in: "query", key: "limit" },
- { in: "query", key: "archived" },
+ { key: "config", map: "body" },
],
},
],
)
- return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({
- url: "/experimental/session",
+ return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
+ url: "/config",
...options,
...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
})
}
-}
-export class Resource extends HeyApiClient {
/**
- * Get MCP resources
+ * List config providers
*
- * Get all available MCP resources from connected servers. Optionally filter by name.
+ * Get a list of all configured AI providers and their default models.
*/
- public list<ThrowOnError extends boolean = false>(
+ public providers<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
@@ -1366,36 +1389,14 @@ export class Resource extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
- url: "/experimental/resource",
+ return (options?.client ?? this.client).get<ConfigProvidersResponses, unknown, ThrowOnError>({
+ url: "/config/providers",
...options,
...params,
})
}
}
-export class Experimental extends HeyApiClient {
- private _console?: Console
- get console(): Console {
- return (this._console ??= new Console({ client: this.client }))
- }
-
- private _workspace?: Workspace
- get workspace(): Workspace {
- return (this._workspace ??= new Workspace({ client: this.client }))
- }
-
- private _session?: Session
- get session(): Session {
- return (this._session ??= new Session({ client: this.client }))
- }
-
- private _resource?: Resource
- get resource(): Resource {
- return (this._resource ??= new Resource({ client: this.client }))
- }
-}
-
export class Tool extends HeyApiClient {
/**
* List tool IDs
@@ -3038,7 +3039,7 @@ export class History extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get<SyncHistoryListResponses, SyncHistoryListErrors, ThrowOnError>({
+ return (options?.client ?? this.client).post<SyncHistoryListResponses, SyncHistoryListErrors, ThrowOnError>({
url: "/sync/history",
...options,
...params,
@@ -3053,6 +3054,36 @@ export class History extends HeyApiClient {
export class Sync extends HeyApiClient {
/**
+ * Start workspace sync
+ *
+ * Start sync loops for workspaces in the current project that have active sessions.
+ */
+ public start<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<SyncStartResponses, unknown, ThrowOnError>({
+ url: "/sync/start",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
* Replay sync events
*
* Validate and replay a complete sync event history.
@@ -4314,6 +4345,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._app ??= new App({ client: this.client }))
}
+ private _experimental?: Experimental
+ get experimental(): Experimental {
+ return (this._experimental ??= new Experimental({ client: this.client }))
+ }
+
private _project?: Project
get project(): Project {
return (this._project ??= new Project({ client: this.client }))
@@ -4329,11 +4365,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._config ??= new Config2({ client: this.client }))
}
- private _experimental?: Experimental
- get experimental(): Experimental {
- return (this._experimental ??= new Experimental({ client: this.client }))
- }
-
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 25c3cfa66..72a383a60 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -535,7 +535,6 @@ export type EventWorkspaceStatus = {
properties: {
workspaceID: string
status: "connected" | "connecting" | "disconnected" | "error"
- error?: string
}
}
@@ -1706,6 +1705,16 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
+export type Workspace = {
+ id: string
+ type: string
+ name: string
+ branch: string | null
+ directory: string | null
+ extra: unknown | null
+ projectID: string
+}
+
export type NotFoundError = {
name: "NotFoundError"
data: {
@@ -1798,6 +1807,12 @@ export type Provider = {
}
}
+export type ConsoleState = {
+ consoleManagedProviders: Array<string>
+ activeOrgName?: string
+ switchableOrgCount: number
+}
+
export type ToolIds = Array<string>
export type ToolListItem = {
@@ -1808,16 +1823,6 @@ export type ToolListItem = {
export type ToolList = Array<ToolListItem>
-export type Workspace = {
- id: string
- type: string
- name: string
- branch: string | null
- directory: string | null
- extra: unknown | null
- projectID: string
-}
-
export type Worktree = {
name: string
branch: string
@@ -2394,6 +2399,176 @@ export type AppLogResponses = {
export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
+export type ExperimentalWorkspaceAdaptorListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace/adaptor"
+}
+
+export type ExperimentalWorkspaceAdaptorListResponses = {
+ /**
+ * Workspace adaptors
+ */
+ 200: Array<{
+ type: string
+ name: string
+ description: string
+ }>
+}
+
+export type ExperimentalWorkspaceAdaptorListResponse =
+ ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
+
+export type ExperimentalWorkspaceListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace"
+}
+
+export type ExperimentalWorkspaceListResponses = {
+ /**
+ * Workspaces
+ */
+ 200: Array<Workspace>
+}
+
+export type ExperimentalWorkspaceListResponse =
+ ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses]
+
+export type ExperimentalWorkspaceCreateData = {
+ body?: {
+ id?: string
+ type: string
+ branch: string | null
+ extra: unknown | null
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace"
+}
+
+export type ExperimentalWorkspaceCreateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ExperimentalWorkspaceCreateError =
+ ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors]
+
+export type ExperimentalWorkspaceCreateResponses = {
+ /**
+ * Workspace created
+ */
+ 200: Workspace
+}
+
+export type ExperimentalWorkspaceCreateResponse =
+ ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
+
+export type ExperimentalWorkspaceStatusData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace/status"
+}
+
+export type ExperimentalWorkspaceStatusResponses = {
+ /**
+ * Workspace status
+ */
+ 200: Array<{
+ workspaceID: string
+ status: "connected" | "connecting" | "disconnected" | "error"
+ }>
+}
+
+export type ExperimentalWorkspaceStatusResponse =
+ ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
+
+export type ExperimentalWorkspaceRemoveData = {
+ body?: never
+ path: {
+ id: string
+ }
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace/{id}"
+}
+
+export type ExperimentalWorkspaceRemoveErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ExperimentalWorkspaceRemoveError =
+ ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors]
+
+export type ExperimentalWorkspaceRemoveResponses = {
+ /**
+ * Workspace removed
+ */
+ 200: Workspace
+}
+
+export type ExperimentalWorkspaceRemoveResponse =
+ ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
+
+export type ExperimentalWorkspaceSessionRestoreData = {
+ body?: {
+ sessionID: string
+ }
+ path: {
+ id: string
+ }
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/experimental/workspace/{id}/session-restore"
+}
+
+export type ExperimentalWorkspaceSessionRestoreErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type ExperimentalWorkspaceSessionRestoreError =
+ ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
+
+export type ExperimentalWorkspaceSessionRestoreResponses = {
+ /**
+ * Session replay started
+ */
+ 200: {
+ total: number
+ }
+}
+
+export type ExperimentalWorkspaceSessionRestoreResponse =
+ ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
+
export type ProjectListData = {
body?: never
path?: never
@@ -2764,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = {
/**
* Active Console provider metadata
*/
- 200: {
- consoleManagedProviders: Array<string>
- activeOrgName?: string
- switchableOrgCount: number
- }
+ 200: ConsoleState
}
export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
@@ -2883,177 +3054,6 @@ export type ToolListResponses = {
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
-export type ExperimentalWorkspaceAdaptorListData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/experimental/workspace/adaptor"
-}
-
-export type ExperimentalWorkspaceAdaptorListResponses = {
- /**
- * Workspace adaptors
- */
- 200: Array<{
- type: string
- name: string
- description: string
- }>
-}
-
-export type ExperimentalWorkspaceAdaptorListResponse =
- ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
-
-export type ExperimentalWorkspaceListData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/experimental/workspace"
-}
-
-export type ExperimentalWorkspaceListResponses = {
- /**
- * Workspaces
- */
- 200: Array<Workspace>
-}
-
-export type ExperimentalWorkspaceListResponse =
- ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses]
-
-export type ExperimentalWorkspaceCreateData = {
- body?: {
- id?: string
- type: string
- branch: string | null
- extra: unknown | null
- }
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/experimental/workspace"
-}
-
-export type ExperimentalWorkspaceCreateErrors = {
- /**
- * Bad request
- */
- 400: BadRequestError
-}
-
-export type ExperimentalWorkspaceCreateError =
- ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors]
-
-export type ExperimentalWorkspaceCreateResponses = {
- /**
- * Workspace created
- */
- 200: Workspace
-}
-
-export type ExperimentalWorkspaceCreateResponse =
- ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
-
-export type ExperimentalWorkspaceStatusData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/experimental/workspace/status"
-}
-
-export type ExperimentalWorkspaceStatusResponses = {
- /**
- * Workspace status
- */
- 200: Array<{
- workspaceID: string
- status: "connected" | "connecting" | "disconnected" | "error"
- error?: string
- }>
-}
-
-export type ExperimentalWorkspaceStatusResponse =
- ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses]
-
-export type ExperimentalWorkspaceRemoveData = {
- body?: never
- path: {
- id: string
- }
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/experimental/workspace/{id}"
-}
-
-export type ExperimentalWorkspaceRemoveErrors = {
- /**
- * Bad request
- */
- 400: BadRequestError
-}
-
-export type ExperimentalWorkspaceRemoveError =
- ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors]
-
-export type ExperimentalWorkspaceRemoveResponses = {
- /**
- * Workspace removed
- */
- 200: Workspace
-}
-
-export type ExperimentalWorkspaceRemoveResponse =
- ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses]
-
-export type ExperimentalWorkspaceSessionRestoreData = {
- body?: {
- sessionID: string
- }
- path: {
- id: string
- }
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/experimental/workspace/{id}/session-restore"
-}
-
-export type ExperimentalWorkspaceSessionRestoreErrors = {
- /**
- * Bad request
- */
- 400: BadRequestError
-}
-
-export type ExperimentalWorkspaceSessionRestoreError =
- ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors]
-
-export type ExperimentalWorkspaceSessionRestoreResponses = {
- /**
- * Session replay started
- */
- 200: {
- total: number
- }
-}
-
-export type ExperimentalWorkspaceSessionRestoreResponse =
- ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses]
-
export type WorktreeRemoveData = {
body?: WorktreeRemoveInput
path?: never
@@ -4504,6 +4504,25 @@ export type ProviderOauthCallbackResponses = {
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
+export type SyncStartData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/sync/start"
+}
+
+export type SyncStartResponses = {
+ /**
+ * Workspace sync started
+ */
+ 200: boolean
+}
+
+export type SyncStartResponse = SyncStartResponses[keyof SyncStartResponses]
+
export type SyncReplayData = {
body?: {
directory: string
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 9193b11ad..b97d596b9 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -415,6 +415,391 @@
]
}
},
+ "/experimental/workspace/adaptor": {
+ "get": {
+ "operationId": "experimental.workspace.adaptor.list",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "List workspace adaptors",
+ "description": "List all available workspace adaptors for the current project.",
+ "responses": {
+ "200": {
+ "description": "Workspace adaptors",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ },
+ "required": ["type", "name", "description"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})"
+ }
+ ]
+ }
+ },
+ "/experimental/workspace": {
+ "post": {
+ "operationId": "experimental.workspace.create",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Create workspace",
+ "description": "Create a workspace for the current project.",
+ "responses": {
+ "200": {
+ "description": "Workspace created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Workspace"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestError"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "type": {
+ "type": "string"
+ },
+ "branch": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "extra": {
+ "anyOf": [
+ {},
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": ["type", "branch", "extra"]
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})"
+ }
+ ]
+ },
+ "get": {
+ "operationId": "experimental.workspace.list",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "List workspaces",
+ "description": "List all workspaces.",
+ "responses": {
+ "200": {
+ "description": "Workspaces",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Workspace"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})"
+ }
+ ]
+ }
+ },
+ "/experimental/workspace/status": {
+ "get": {
+ "operationId": "experimental.workspace.status",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Workspace status",
+ "description": "Get connection status for workspaces in the current project.",
+ "responses": {
+ "200": {
+ "description": "Workspace status",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "workspaceID": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["connected", "connecting", "disconnected", "error"]
+ }
+ },
+ "required": ["workspaceID", "status"]
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})"
+ }
+ ]
+ }
+ },
+ "/experimental/workspace/{id}": {
+ "delete": {
+ "operationId": "experimental.workspace.remove",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "id",
+ "schema": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "required": true
+ }
+ ],
+ "summary": "Remove workspace",
+ "description": "Remove an existing workspace.",
+ "responses": {
+ "200": {
+ "description": "Workspace removed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Workspace"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestError"
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})"
+ }
+ ]
+ }
+ },
+ "/experimental/workspace/{id}/session-restore": {
+ "post": {
+ "operationId": "experimental.workspace.sessionRestore",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "path",
+ "name": "id",
+ "schema": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "required": true
+ }
+ ],
+ "summary": "Restore session into workspace",
+ "description": "Replay a session's sync events into the target workspace in batches.",
+ "responses": {
+ "200": {
+ "description": "Session replay started",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ }
+ },
+ "required": ["total"]
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BadRequestError"
+ }
+ }
+ }
+ }
+ },
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "sessionID": {
+ "type": "string",
+ "pattern": "^ses.*"
+ }
+ },
+ "required": ["sessionID"]
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})"
+ }
+ ]
+ }
+ },
"/project": {
"get": {
"operationId": "project.list",
@@ -1222,24 +1607,7 @@
"content": {
"application/json": {
"schema": {
- "type": "object",
- "properties": {
- "consoleManagedProviders": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "activeOrgName": {
- "type": "string"
- },
- "switchableOrgCount": {
- "type": "integer",
- "minimum": 0,
- "maximum": 9007199254740991
- }
- },
- "required": ["consoleManagedProviders", "switchableOrgCount"]
+ "$ref": "#/components/schemas/ConsoleState"
}
}
}
@@ -1501,394 +1869,6 @@
]
}
},
- "/experimental/workspace/adaptor": {
- "get": {
- "operationId": "experimental.workspace.adaptor.list",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- }
- ],
- "summary": "List workspace adaptors",
- "description": "List all available workspace adaptors for the current project.",
- "responses": {
- "200": {
- "description": "Workspace adaptors",
- "content": {
- "application/json": {
- "schema": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "type": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "description": {
- "type": "string"
- }
- },
- "required": ["type", "name", "description"]
- }
- }
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})"
- }
- ]
- }
- },
- "/experimental/workspace": {
- "post": {
- "operationId": "experimental.workspace.create",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- }
- ],
- "summary": "Create workspace",
- "description": "Create a workspace for the current project.",
- "responses": {
- "200": {
- "description": "Workspace created",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Workspace"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/BadRequestError"
- }
- }
- }
- }
- },
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "type": {
- "type": "string"
- },
- "branch": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ]
- },
- "extra": {
- "anyOf": [
- {},
- {
- "type": "null"
- }
- ]
- }
- },
- "required": ["type", "branch", "extra"]
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})"
- }
- ]
- },
- "get": {
- "operationId": "experimental.workspace.list",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- }
- ],
- "summary": "List workspaces",
- "description": "List all workspaces.",
- "responses": {
- "200": {
- "description": "Workspaces",
- "content": {
- "application/json": {
- "schema": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/Workspace"
- }
- }
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})"
- }
- ]
- }
- },
- "/experimental/workspace/status": {
- "get": {
- "operationId": "experimental.workspace.status",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- }
- ],
- "summary": "Workspace status",
- "description": "Get connection status for workspaces in the current project.",
- "responses": {
- "200": {
- "description": "Workspace status",
- "content": {
- "application/json": {
- "schema": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "workspaceID": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "status": {
- "type": "string",
- "enum": ["connected", "connecting", "disconnected", "error"]
- },
- "error": {
- "type": "string"
- }
- },
- "required": ["workspaceID", "status"]
- }
- }
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})"
- }
- ]
- }
- },
- "/experimental/workspace/{id}": {
- "delete": {
- "operationId": "experimental.workspace.remove",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "path",
- "name": "id",
- "schema": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "required": true
- }
- ],
- "summary": "Remove workspace",
- "description": "Remove an existing workspace.",
- "responses": {
- "200": {
- "description": "Workspace removed",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/Workspace"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/BadRequestError"
- }
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})"
- }
- ]
- }
- },
- "/experimental/workspace/{id}/session-restore": {
- "post": {
- "operationId": "experimental.workspace.sessionRestore",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "path",
- "name": "id",
- "schema": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "required": true
- }
- ],
- "summary": "Restore session into workspace",
- "description": "Replay a session's sync events into the target workspace in batches.",
- "responses": {
- "200": {
- "description": "Session replay started",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "total": {
- "type": "integer",
- "minimum": 0,
- "maximum": 9007199254740991
- }
- },
- "required": ["total"]
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/BadRequestError"
- }
- }
- }
- }
- },
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "sessionID": {
- "type": "string",
- "pattern": "^ses.*"
- }
- },
- "required": ["sessionID"]
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})"
- }
- ]
- }
- },
"/experimental/worktree": {
"post": {
"operationId": "worktree.create",
@@ -5227,6 +5207,47 @@
]
}
},
+ "/sync/start": {
+ "post": {
+ "operationId": "sync.start",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Start workspace sync",
+ "description": "Start sync loops for workspaces in the current project that have active sessions.",
+ "responses": {
+ "200": {
+ "description": "Workspace sync started",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.start({\n ...\n})"
+ }
+ ]
+ }
+ },
"/sync/replay": {
"post": {
"operationId": "sync.replay",
@@ -5331,7 +5352,7 @@
}
},
"/sync/history": {
- "get": {
+ "post": {
"operationId": "sync.history.list",
"parameters": [
{
@@ -8852,9 +8873,6 @@
"status": {
"type": "string",
"enum": ["connected", "connecting", "disconnected", "error"]
- },
- "error": {
- "type": "string"
}
},
"required": ["workspaceID", "status"]
@@ -10931,8 +10949,7 @@
"type": "string"
}
}
- },
- "additionalProperties": false
+ }
},
"PermissionActionConfig": {
"type": "string",
@@ -11162,13 +11179,11 @@
"description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"anyOf": [
{
- "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
{
- "description": "Disable timeout for this provider entirely.",
"type": "boolean",
"const": false
}
@@ -11229,8 +11244,7 @@
"enum": ["reasoning_content", "reasoning_details"]
}
},
- "required": ["field"],
- "additionalProperties": false
+ "required": ["field"]
}
]
},
@@ -11359,8 +11373,7 @@
}
}
}
- },
- "additionalProperties": false
+ }
},
"McpLocalConfig": {
"type": "object",
@@ -11393,13 +11406,10 @@
},
"timeout": {
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
- "type": "integer",
- "exclusiveMinimum": 0,
- "maximum": 9007199254740991
+ "type": "number"
}
},
- "required": ["type", "command"],
- "additionalProperties": false
+ "required": ["type", "command"]
},
"McpOAuthConfig": {
"type": "object",
@@ -11420,8 +11430,7 @@
"description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
"type": "string"
}
- },
- "additionalProperties": false
+ }
},
"McpRemoteConfig": {
"type": "object",
@@ -11463,13 +11472,10 @@
},
"timeout": {
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
- "type": "integer",
- "exclusiveMinimum": 0,
- "maximum": 9007199254740991
+ "type": "number"
}
},
- "required": ["type", "url"],
- "additionalProperties": false
+ "required": ["type", "url"]
},
"LayoutConfig": {
"description": "@deprecated Always uses stretch layout.",
@@ -12003,6 +12009,53 @@
}
]
},
+ "Workspace": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^wrk.*"
+ },
+ "type": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "branch": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "directory": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "extra": {
+ "anyOf": [
+ {},
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "projectID": {
+ "type": "string"
+ }
+ },
+ "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"]
+ },
"NotFoundError": {
"type": "object",
"properties": {
@@ -12284,6 +12337,24 @@
},
"required": ["id", "name", "source", "env", "options", "models"]
},
+ "ConsoleState": {
+ "type": "object",
+ "properties": {
+ "consoleManagedProviders": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "activeOrgName": {
+ "type": "string"
+ },
+ "switchableOrgCount": {
+ "type": "number"
+ }
+ },
+ "required": ["consoleManagedProviders", "switchableOrgCount"]
+ },
"ToolIDs": {
"type": "array",
"items": {
@@ -12309,53 +12380,6 @@
"$ref": "#/components/schemas/ToolListItem"
}
},
- "Workspace": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string",
- "pattern": "^wrk.*"
- },
- "type": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "branch": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ]
- },
- "directory": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ]
- },
- "extra": {
- "anyOf": [
- {},
- {
- "type": "null"
- }
- ]
- },
- "projectID": {
- "type": "string"
- }
- },
- "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"]
- },
"Worktree": {
"type": "object",
"properties": {
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 9dec6bdb6..b7fffcadb 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.4.7",
+ "version": "1.14.18",
"name": "@opencode-ai/shared",
"type": "module",
"license": "MIT",
diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts
deleted file mode 100644
index 865e827b3..000000000
--- a/packages/shared/src/npm.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import path from "path"
-import semver from "semver"
-import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
-import { NodeFileSystem } from "@effect/platform-node"
-import { AppFileSystem } from "./filesystem"
-import { Global } from "./global"
-import { EffectFlock } from "./util/effect-flock"
-
-export namespace Npm {
- export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
- add: Schema.Array(Schema.String).pipe(Schema.optional),
- dir: Schema.String,
- cause: Schema.optional(Schema.Defect),
- }) {}
-
- export interface EntryPoint {
- readonly directory: string
- readonly entrypoint: Option.Option<string>
- }
-
- export interface Interface {
- readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
- readonly install: (
- dir: string,
- input?: { add: string[] },
- ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
- readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
- readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
- }
-
- export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
-
- const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
-
- export function sanitize(pkg: string) {
- if (!illegal) return pkg
- return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
- }
-
- const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
- let entrypoint: Option.Option<string>
- try {
- const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
- entrypoint = Option.some(resolved)
- } catch {
- entrypoint = Option.none()
- }
- return {
- directory: dir,
- entrypoint,
- }
- }
-
- interface ArboristNode {
- name: string
- path: string
- }
-
- interface ArboristTree {
- edgesOut: Map<string, { to?: ArboristNode }>
- }
-
- const reify = (input: { dir: string; add?: string[] }) =>
- Effect.gen(function* () {
- const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
- const arborist = new Arborist({
- path: input.dir,
- binLinks: true,
- progress: false,
- savePrefix: "",
- ignoreScripts: true,
- })
- return yield* Effect.tryPromise({
- try: () =>
- arborist.reify({
- add: input?.add || [],
- save: true,
- saveType: "prod",
- }),
- catch: (cause) =>
- new InstallFailedError({
- cause,
- add: input?.add,
- dir: input.dir,
- }),
- }) as Effect.Effect<ArboristTree, InstallFailedError>
- }).pipe(
- Effect.withSpan("Npm.reify", {
- attributes: input,
- }),
- )
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const afs = yield* AppFileSystem.Service
- const global = yield* Global.Service
- const fs = yield* FileSystem.FileSystem
- const flock = yield* EffectFlock.Service
- const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
-
- const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
- const response = yield* Effect.tryPromise({
- try: () => fetch(`https://registry.npmjs.org/${pkg}`),
- catch: () => undefined,
- }).pipe(Effect.orElseSucceed(() => undefined))
-
- if (!response || !response.ok) {
- return false
- }
-
- const data = yield* Effect.tryPromise({
- try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
- catch: () => undefined,
- }).pipe(Effect.orElseSucceed(() => undefined))
-
- const latestVersion = data?.["dist-tags"]?.latest
- if (!latestVersion) {
- return false
- }
-
- const range = /[\s^~*xX<>|=]/.test(cachedVersion)
- if (range) return !semver.satisfies(latestVersion, cachedVersion)
-
- return semver.lt(cachedVersion, latestVersion)
- })
-
- const add = Effect.fn("Npm.add")(function* (pkg: string) {
- const dir = directory(pkg)
- yield* flock.acquire(`npm-install:${dir}`)
-
- const tree = yield* reify({ dir, add: [pkg] })
- const first = tree.edgesOut.values().next().value?.to
- if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
- return resolveEntryPoint(first.name, first.path)
- }, Effect.scoped)
-
- const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
- const canWrite = yield* afs.access(dir, { writable: true }).pipe(
- Effect.as(true),
- Effect.orElseSucceed(() => false),
- )
- if (!canWrite) return
-
- yield* flock.acquire(`npm-install:${dir}`)
-
- yield* Effect.gen(function* () {
- const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
- if (!nodeModulesExists) {
- yield* reify({ add: input?.add, dir })
- return
- }
- }).pipe(Effect.withSpan("Npm.checkNodeModules"))
-
- yield* Effect.gen(function* () {
- const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
- const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
-
- const pkgAny = pkg as any
- const lockAny = lock as any
- const declared = new Set([
- ...Object.keys(pkgAny?.dependencies || {}),
- ...Object.keys(pkgAny?.devDependencies || {}),
- ...Object.keys(pkgAny?.peerDependencies || {}),
- ...Object.keys(pkgAny?.optionalDependencies || {}),
- ...(input?.add || []),
- ])
-
- const root = lockAny?.packages?.[""] || {}
- const locked = new Set([
- ...Object.keys(root?.dependencies || {}),
- ...Object.keys(root?.devDependencies || {}),
- ...Object.keys(root?.peerDependencies || {}),
- ...Object.keys(root?.optionalDependencies || {}),
- ])
-
- for (const name of declared) {
- if (!locked.has(name)) {
- yield* reify({ dir, add: input?.add })
- return
- }
- }
- }).pipe(Effect.withSpan("Npm.checkDirty"))
-
- return
- }, Effect.scoped)
-
- const which = Effect.fn("Npm.which")(function* (pkg: string) {
- const dir = directory(pkg)
- const binDir = path.join(dir, "node_modules", ".bin")
-
- const pick = Effect.fnUntraced(function* () {
- const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
-
- if (files.length === 0) return Option.none<string>()
- if (files.length === 1) return Option.some(files[0])
-
- const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
-
- if (Option.isSome(pkgJson)) {
- const parsed = pkgJson.value as { bin?: string | Record<string, string> }
- if (parsed?.bin) {
- const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
- const bin = parsed.bin
- if (typeof bin === "string") return Option.some(unscoped)
- const keys = Object.keys(bin)
- if (keys.length === 1) return Option.some(keys[0])
- return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
- }
- }
-
- return Option.some(files[0])
- })
-
- return yield* Effect.gen(function* () {
- const bin = yield* pick()
- if (Option.isSome(bin)) {
- return Option.some(path.join(binDir, bin.value))
- }
-
- yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
-
- yield* add(pkg)
-
- const resolved = yield* pick()
- if (Option.isNone(resolved)) return Option.none<string>()
- return Option.some(path.join(binDir, resolved.value))
- }).pipe(
- Effect.scoped,
- Effect.orElseSucceed(() => Option.none<string>()),
- )
- })
-
- return Service.of({
- add,
- install,
- outdated,
- which,
- })
- }),
- )
-
- export const defaultLayer = layer.pipe(
- Layer.provide(EffectFlock.layer),
- Layer.provide(AppFileSystem.layer),
- Layer.provide(Global.layer),
- Layer.provide(NodeFileSystem.layer),
- )
-}
diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts
index 3e00afc9e..16bcf091b 100644
--- a/packages/shared/src/util/effect-flock.ts
+++ b/packages/shared/src/util/effect-flock.ts
@@ -165,55 +165,60 @@ export namespace EffectFlock {
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
- const tryAcquireLockDir = Effect.fn("EffectFlock.tryAcquire")(function* (lockDir: string) {
- const token = randomUUID()
- const metaPath = path.join(lockDir, "meta.json")
- const heartbeatPath = path.join(lockDir, "heartbeat")
-
- // Atomic mkdir — the POSIX lock primitive
- const created = yield* atomicMkdir(lockDir)
-
- if (!created) {
- if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
-
- // Stale — race for breaker ownership
- const breakerPath = lockDir + ".breaker"
-
- const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
- Effect.as(true),
- Effect.catchIf(
- (e) => e.reason._tag === "AlreadyExists",
- () => cleanStaleBreaker(breakerPath),
- ),
- Effect.catchIf(isPathGone, () => Effect.succeed(false)),
- Effect.orDie,
- )
-
- if (!claimed) return yield* new NotAcquired()
-
- // We own the breaker — double-check staleness, nuke, recreate
- const recreated = yield* Effect.gen(function* () {
- if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
- yield* forceRemove(lockDir)
- return yield* atomicMkdir(lockDir)
- }).pipe(Effect.ensuring(forceRemove(breakerPath)))
+ const tryAcquireLockDir = (lockDir: string, key: string) =>
+ Effect.gen(function* () {
+ const token = randomUUID()
+ const metaPath = path.join(lockDir, "meta.json")
+ const heartbeatPath = path.join(lockDir, "heartbeat")
+
+ // Atomic mkdir — the POSIX lock primitive
+ const created = yield* atomicMkdir(lockDir)
+
+ if (!created) {
+ if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
+
+ // Stale — race for breaker ownership
+ const breakerPath = lockDir + ".breaker"
+
+ const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
+ Effect.as(true),
+ Effect.catchIf(
+ (e) => e.reason._tag === "AlreadyExists",
+ () => cleanStaleBreaker(breakerPath),
+ ),
+ Effect.catchIf(isPathGone, () => Effect.succeed(false)),
+ Effect.orDie,
+ )
+
+ if (!claimed) return yield* new NotAcquired()
+
+ // We own the breaker — double-check staleness, nuke, recreate
+ const recreated = yield* Effect.gen(function* () {
+ if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
+ yield* forceRemove(lockDir)
+ return yield* atomicMkdir(lockDir)
+ }).pipe(Effect.ensuring(forceRemove(breakerPath)))
- if (!recreated) return yield* new NotAcquired()
- }
+ if (!recreated) return yield* new NotAcquired()
+ }
- // We own the lock dir — write heartbeat + meta with exclusive create
- yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
+ // We own the lock dir — write heartbeat + meta with exclusive create
+ yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
- const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
- yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
+ const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
+ yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
- return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
- })
+ return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
+ }).pipe(
+ Effect.withSpan("EffectFlock.tryAcquire", {
+ attributes: { key },
+ }),
+ )
// -- retry wrapper (preserves Handle type) --
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
- tryAcquireLockDir(lockfile).pipe(
+ tryAcquireLockDir(lockfile, key).pipe(
Effect.retry({
while: (err) => err._tag === "NotAcquired",
schedule: retrySchedule,
diff --git a/packages/shared/sst-env.d.ts b/packages/shared/sst-env.d.ts
new file mode 100644
index 000000000..64441936d
--- /dev/null
+++ b/packages/shared/sst-env.d.ts
@@ -0,0 +1,10 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+/* biome-ignore-all lint: auto-generated */
+
+/// <reference path="../../sst-env.d.ts" />
+
+import "sst"
+export {} \ No newline at end of file
diff --git a/packages/shared/test/npm.test.ts b/packages/shared/test/npm.test.ts
deleted file mode 100644
index 4443d2985..000000000
--- a/packages/shared/test/npm.test.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { Npm } from "@opencode-ai/shared/npm"
-
-const win = process.platform === "win32"
-
-describe("Npm.sanitize", () => {
- test("keeps normal scoped package specs unchanged", () => {
- expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
- expect(Npm.sanitize("@opencode/[email protected]")).toBe("@opencode/[email protected]")
- expect(Npm.sanitize("prettier")).toBe("prettier")
- })
-
- test("handles git https specs", () => {
- const spec = "acme@git+https://github.com/opencode/acme.git"
- const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
- expect(Npm.sanitize(spec)).toBe(expected)
- })
-})
diff --git a/packages/slack/package.json b/packages/slack/package.json
index a23500241..39dc9ab3c 100644
--- a/packages/slack/package.json
+++ b/packages/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index cd559041c..723cda40d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
- "version": "1.4.7",
+ "version": "1.14.18",
"type": "module",
"license": "MIT",
"exports": {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index a47ff1804..9c0c90c00 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1269,7 +1269,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
<Accordion
multiple
data-scope="apply-patch"
- style={{ "--sticky-accordion-offset": "40px" }}
+ style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
defaultValue={[value()]}
>
<Accordion.Item value={value()}>
@@ -2061,7 +2061,7 @@ ToolRegistry.register({
<Accordion
multiple
data-scope="apply-patch"
- style={{ "--sticky-accordion-offset": "40px" }}
+ style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts
index f9ddfdf0e..5a9728750 100644
--- a/packages/ui/src/components/provider-icons/types.ts
+++ b/packages/ui/src/components/provider-icons/types.ts
@@ -32,6 +32,7 @@ export const iconNames = [
"perplexity",
"ovhcloud",
"openrouter",
+ "llmgateway",
"opencode",
"opencode-go",
"openai",
diff --git a/packages/web/package.json b/packages/web/package.json
index a53ef5193..51be7fe4a 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
- "version": "1.4.7",
+ "version": "1.14.18",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
diff --git a/packages/web/src/content/docs/ar/cli.mdx b/packages/web/src/content/docs/ar/cli.mdx
index 826ea4304..ab2c12fb2 100644
--- a/packages/web/src/content/docs/ar/cli.mdx
+++ b/packages/web/src/content/docs/ar/cli.mdx
@@ -573,7 +573,6 @@ opencode upgrade v0.1.48
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | تعطيل تحميل `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | تعطيل جلب النماذج من مصادر بعيدة |
| `OPENCODE_FAKE_VCS` | string | مزود VCS وهمي لأغراض الاختبار |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | تعطيل التحقق من وقت الملف لتحسين الأداء |
| `OPENCODE_CLIENT` | string | معرّف العميل (الافتراضي `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | تفعيل أدوات بحث الويب من Exa |
| `OPENCODE_SERVER_PASSWORD` | string | تفعيل المصادقة الأساسية لخادمي `serve`/`web` |
diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx
index 979066acb..118b81ba4 100644
--- a/packages/web/src/content/docs/bs/cli.mdx
+++ b/packages/web/src/content/docs/bs/cli.mdx
@@ -571,7 +571,6 @@ OpenCode se može konfigurirati pomoću varijabli okruženja.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Onemogući učitavanje `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Onemogući dohvaćanje modela iz udaljenih izvora |
| `OPENCODE_FAKE_VCS` | string | Lažni VCS provajder za potrebe testiranja |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Onemogući provjeru vremena datoteke radi optimizacije |
| `OPENCODE_CLIENT` | string | Identifikator klijenta (zadano na `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Omogući Exa alate za web pretraživanje |
| `OPENCODE_SERVER_PASSWORD` | string | Omogući osnovnu autentifikaciju za `serve`/`web` |
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index 579038ad0..fb1130fe5 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/packages/web/src/content/docs/cli.mdx
@@ -335,20 +335,21 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript"
#### Flags
-| Flag | Short | Description |
-| ------------ | ----- | ----------------------------------------------------------------------- |
-| `--command` | | The command to run, use message for args |
-| `--continue` | `-c` | Continue the last session |
-| `--session` | `-s` | Session ID to continue |
-| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) |
-| `--share` | | Share the session |
-| `--model` | `-m` | Model to use in the form of provider/model |
-| `--agent` | | Agent to use |
-| `--file` | `-f` | File(s) to attach to message |
-| `--format` | | Format: default (formatted) or json (raw JSON events) |
-| `--title` | | Title for the session (uses truncated prompt if no value provided) |
-| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) |
-| `--port` | | Port for the local server (defaults to random port) |
+| Flag | Short | Description |
+| -------------------------------- | ----- | ----------------------------------------------------------------------- |
+| `--command` | | The command to run, use message for args |
+| `--continue` | `-c` | Continue the last session |
+| `--session` | `-s` | Session ID to continue |
+| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) |
+| `--share` | | Share the session |
+| `--model` | `-m` | Model to use in the form of provider/model |
+| `--agent` | | Agent to use |
+| `--file` | `-f` | File(s) to attach to message |
+| `--format` | | Format: default (formatted) or json (raw JSON events) |
+| `--title` | | Title for the session (uses truncated prompt if no value provided) |
+| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) |
+| `--port` | | Port for the local server (defaults to random port) |
+| `--dangerously-skip-permissions` | | Auto-approve permissions that are not explicitly denied (dangerous!) |
---
@@ -575,7 +576,6 @@ OpenCode can be configured using environment variables.
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI |
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
| `OPENCODE_SERVER_PASSWORD` | string | Enable basic auth for `serve`/`web` |
diff --git a/packages/web/src/content/docs/da/cli.mdx b/packages/web/src/content/docs/da/cli.mdx
index 40c6645e6..45c4f08e3 100644
--- a/packages/web/src/content/docs/da/cli.mdx
+++ b/packages/web/src/content/docs/da/cli.mdx
@@ -574,7 +574,6 @@ OpenCode kan konfigureres ved hjælp af miljøvariabler.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Deaktiver indlæsning af `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Deaktivering af modeller fra eksterne kilder |
| `OPENCODE_FAKE_VCS` | string | Falsk VCS-udbyder til testformål |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Deaktiver filtidskontrol for optimering |
| `OPENCODE_CLIENT` | string | Klient-id (standard til `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Aktiver Exa-websøgeværktøjer |
| `OPENCODE_SERVER_PASSWORD` | string | Aktiver grundlæggende godkendelse for `serve`/`web` |
diff --git a/packages/web/src/content/docs/de/cli.mdx b/packages/web/src/content/docs/de/cli.mdx
index cb1b974e1..43a1189d6 100644
--- a/packages/web/src/content/docs/de/cli.mdx
+++ b/packages/web/src/content/docs/de/cli.mdx
@@ -573,7 +573,6 @@ OpenCode kann mithilfe von Umgebungsvariablen konfiguriert werden.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolescher Wert | Deaktivieren Sie das Laden von `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolescher Wert | Deaktivieren Sie das Abrufen von Modellen aus Remote-Quellen |
| `OPENCODE_FAKE_VCS` | Zeichenfolge | Gefälschter VCS-Anbieter zu Testzwecken |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolescher Wert | Dateizeitprüfung zur Optimierung deaktivieren |
| `OPENCODE_CLIENT` | Zeichenfolge | Client-ID (standardmäßig `cli`) |
| `OPENCODE_ENABLE_EXA` | boolescher Wert | Exa-Websuchtools aktivieren |
| `OPENCODE_SERVER_PASSWORD` | Zeichenfolge | Aktivieren Sie die Basisauthentifizierung für `serve`/`web` |
diff --git a/packages/web/src/content/docs/es/cli.mdx b/packages/web/src/content/docs/es/cli.mdx
index 658be2708..5c86474a6 100644
--- a/packages/web/src/content/docs/es/cli.mdx
+++ b/packages/web/src/content/docs/es/cli.mdx
@@ -573,7 +573,6 @@ OpenCode se puede configurar mediante variables de entorno.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | booleano | Deshabilitar la carga `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | booleano | Deshabilitar la recuperación de modelos desde fuentes remotas |
| `OPENCODE_FAKE_VCS` | cadena | Proveedor de VCS falso para fines de prueba |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | booleano | Deshabilite la verificación del tiempo del archivo para optimizarlo |
| `OPENCODE_CLIENT` | cadena | Identificador de cliente (por defecto `cli`) |
| `OPENCODE_ENABLE_EXA` | booleano | Habilitar las herramientas de búsqueda web de Exa |
| `OPENCODE_SERVER_PASSWORD` | cadena | Habilite la autenticación básica para `serve`/`web` |
diff --git a/packages/web/src/content/docs/fr/cli.mdx b/packages/web/src/content/docs/fr/cli.mdx
index 2c763618e..cffa748ad 100644
--- a/packages/web/src/content/docs/fr/cli.mdx
+++ b/packages/web/src/content/docs/fr/cli.mdx
@@ -574,7 +574,6 @@ OpenCode peut être configuré à l'aide de variables d'environnement.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | booléen | Désactiver le chargement de `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | booléen | Désactiver la récupération de modèles à partir de sources distantes |
| `OPENCODE_FAKE_VCS` | chaîne | Faux fournisseur VCS à des fins de test |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | booléen | Désactiver la vérification de l'heure des fichiers pour l'optimisation |
| `OPENCODE_CLIENT` | chaîne | Identifiant du client (par défaut `cli`) |
| `OPENCODE_ENABLE_EXA` | booléen | Activer les outils de recherche Web Exa |
| `OPENCODE_SERVER_PASSWORD` | chaîne | Activer l'authentification de base pour `serve`/`web` |
diff --git a/packages/web/src/content/docs/it/cli.mdx b/packages/web/src/content/docs/it/cli.mdx
index 46d7da149..952dfba09 100644
--- a/packages/web/src/content/docs/it/cli.mdx
+++ b/packages/web/src/content/docs/it/cli.mdx
@@ -574,7 +574,6 @@ OpenCode può essere configurato tramite variabili d'ambiente.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disabilita caricamento di `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disabilita fetch dei modelli da fonti remote |
| `OPENCODE_FAKE_VCS` | string | Provider VCS finto per scopi di test |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disabilita controllo file time per ottimizzazione |
| `OPENCODE_CLIENT` | string | Identificatore client (default `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Abilita gli strumenti di web search Exa |
| `OPENCODE_SERVER_PASSWORD` | string | Abilita basic auth per `serve`/`web` |
diff --git a/packages/web/src/content/docs/ja/cli.mdx b/packages/web/src/content/docs/ja/cli.mdx
index f690c7d7e..82a8852ea 100644
--- a/packages/web/src/content/docs/ja/cli.mdx
+++ b/packages/web/src/content/docs/ja/cli.mdx
@@ -573,7 +573,6 @@ OpenCode は環境変数を使用して構成できます。
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | ブール値 | `.claude/skills` のロードを無効にする |
| `OPENCODE_DISABLE_MODELS_FETCH` | ブール値 | リモートソースからのモデルの取得を無効にする |
| `OPENCODE_FAKE_VCS` | 文字列 | テスト目的の偽の VCS プロバイダー |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | ブール値 | 最適化のためにファイル時間チェックを無効にする |
| `OPENCODE_CLIENT` | 文字列 | クライアント識別子 (デフォルトは `cli`) |
| `OPENCODE_ENABLE_EXA` | ブール値 | Exa Web 検索ツールを有効にする |
| `OPENCODE_SERVER_PASSWORD` | 文字列 | `serve`/`web` の基本認証を有効にする |
diff --git a/packages/web/src/content/docs/ko/cli.mdx b/packages/web/src/content/docs/ko/cli.mdx
index 0562ab8af..b0ce10567 100644
--- a/packages/web/src/content/docs/ko/cli.mdx
+++ b/packages/web/src/content/docs/ko/cli.mdx
@@ -573,7 +573,6 @@ OpenCode는 환경 변수로도 구성할 수 있습니다.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | `.claude/skills` 로드 비활성화 |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 원격 소스에서 모델 목록 가져오기 비활성화 |
| `OPENCODE_FAKE_VCS` | string | 테스트용 가짜 VCS provider |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | 최적화를 위한 파일 시간 검사 비활성화 |
| `OPENCODE_CLIENT` | string | 클라이언트 식별자(기본값: `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Exa 웹 검색 도구 활성화 |
| `OPENCODE_SERVER_PASSWORD` | string | `serve`/`web` 기본 인증 활성화 |
diff --git a/packages/web/src/content/docs/nb/cli.mdx b/packages/web/src/content/docs/nb/cli.mdx
index 8b6d283e1..8312a1a7c 100644
--- a/packages/web/src/content/docs/nb/cli.mdx
+++ b/packages/web/src/content/docs/nb/cli.mdx
@@ -574,7 +574,6 @@ OpenCode kan konfigureres ved hjelp av miljøvariabler.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolsk | Deaktiver innlasting av `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolsk | Deaktiver henting av modeller fra eksterne kilder |
| `OPENCODE_FAKE_VCS` | streng | Falsk VCS-leverandør for testformål |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolsk | Deaktiver filtidskontroll for optimalisering |
| `OPENCODE_CLIENT` | streng | Klientidentifikator (standard til `cli`) |
| `OPENCODE_ENABLE_EXA` | boolsk | Aktiver Exa-nettsøkeverktøy |
| `OPENCODE_SERVER_PASSWORD` | streng | Aktiver grunnleggende autentisering for `serve`/`web` |
diff --git a/packages/web/src/content/docs/pl/cli.mdx b/packages/web/src/content/docs/pl/cli.mdx
index 6cdc67a48..e175870cb 100644
--- a/packages/web/src/content/docs/pl/cli.mdx
+++ b/packages/web/src/content/docs/pl/cli.mdx
@@ -553,33 +553,32 @@ Interfejs CLI OpenCode przyjmuje następujące flagi globalne dla każdego polec
OpenCode można skonfigurować za pomocą zmiennych środowiskowych.
-| Zmienna | Typ | Opis |
-| ------------------------------------- | ------- | ----------------------------------------------------------- |
-| `OPENCODE_AUTO_SHARE` | boolean | Automatycznie udostępniaj sesje |
-| `OPENCODE_GIT_BASH_PATH` | string | Ścieżka do pliku wykonywalnego Git Bash w systemie Windows |
-| `OPENCODE_CONFIG` | string | Ścieżka do pliku konfiguracyjnego |
-| `OPENCODE_TUI_CONFIG` | string | Ścieżka do pliku konfiguracyjnego TUI |
-| `OPENCODE_CONFIG_DIR` | string | Ścieżka do katalogu konfiguracyjnego |
-| `OPENCODE_CONFIG_CONTENT` | string | Treść konfiguracji JSON (inline) |
-| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Wyłącz automatyczne sprawdzanie aktualizacji |
-| `OPENCODE_DISABLE_PRUNE` | boolean | Wyłącz czyszczenie starych wyników (pruning) |
-| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Wyłącz automatyczne ustawianie tytułu terminala |
-| `OPENCODE_PERMISSION` | string | Konfiguracja uprawnień w JSON (inline) |
-| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Wyłącz domyślne wtyczki |
-| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Wyłącz automatyczne pobieranie serwerów LSP |
-| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Włącz modele eksperymentalne |
-| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Wyłącz automatyczne kompaktowanie kontekstu |
-| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Wyłącz integrację z `.claude` (prompt + skills) |
-| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Wyłącz czytanie `~/.claude/CLAUDE.md` |
-| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Wyłącz ładowanie `.claude/skills` |
-| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Wyłącz pobieranie modeli ze źródeł zewnętrznych |
-| `OPENCODE_FAKE_VCS` | string | Fałszywy dostawca VCS do celów testowych |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Wyłącz sprawdzanie czasu modyfikacji plików (optymalizacja) |
-| `OPENCODE_CLIENT` | string | Identyfikator klienta (domyślnie `cli`) |
-| `OPENCODE_ENABLE_EXA` | boolean | Włącz narzędzie wyszukiwania internetowego Exa |
-| `OPENCODE_SERVER_PASSWORD` | string | Włącz uwierzytelnianie podstawowe dla `serve`/`web` |
-| `OPENCODE_SERVER_USERNAME` | string | Nazwa użytkownika do autoryzacji (domyślnie `opencode`) |
-| `OPENCODE_MODELS_URL` | string | Niestandardowy adres URL do pobierania konfiguracji modeli |
+| Zmienna | Typ | Opis |
+| ------------------------------------- | ------- | ---------------------------------------------------------- |
+| `OPENCODE_AUTO_SHARE` | boolean | Automatycznie udostępniaj sesje |
+| `OPENCODE_GIT_BASH_PATH` | string | Ścieżka do pliku wykonywalnego Git Bash w systemie Windows |
+| `OPENCODE_CONFIG` | string | Ścieżka do pliku konfiguracyjnego |
+| `OPENCODE_TUI_CONFIG` | string | Ścieżka do pliku konfiguracyjnego TUI |
+| `OPENCODE_CONFIG_DIR` | string | Ścieżka do katalogu konfiguracyjnego |
+| `OPENCODE_CONFIG_CONTENT` | string | Treść konfiguracji JSON (inline) |
+| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Wyłącz automatyczne sprawdzanie aktualizacji |
+| `OPENCODE_DISABLE_PRUNE` | boolean | Wyłącz czyszczenie starych wyników (pruning) |
+| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Wyłącz automatyczne ustawianie tytułu terminala |
+| `OPENCODE_PERMISSION` | string | Konfiguracja uprawnień w JSON (inline) |
+| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Wyłącz domyślne wtyczki |
+| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Wyłącz automatyczne pobieranie serwerów LSP |
+| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Włącz modele eksperymentalne |
+| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Wyłącz automatyczne kompaktowanie kontekstu |
+| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Wyłącz integrację z `.claude` (prompt + skills) |
+| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Wyłącz czytanie `~/.claude/CLAUDE.md` |
+| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Wyłącz ładowanie `.claude/skills` |
+| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Wyłącz pobieranie modeli ze źródeł zewnętrznych |
+| `OPENCODE_FAKE_VCS` | string | Fałszywy dostawca VCS do celów testowych |
+| `OPENCODE_CLIENT` | string | Identyfikator klienta (domyślnie `cli`) |
+| `OPENCODE_ENABLE_EXA` | boolean | Włącz narzędzie wyszukiwania internetowego Exa |
+| `OPENCODE_SERVER_PASSWORD` | string | Włącz uwierzytelnianie podstawowe dla `serve`/`web` |
+| `OPENCODE_SERVER_USERNAME` | string | Nazwa użytkownika do autoryzacji (domyślnie `opencode`) |
+| `OPENCODE_MODELS_URL` | string | Niestandardowy adres URL do pobierania konfiguracji modeli |
---
diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx
index bd7e10f92..bad9e1ebb 100644
--- a/packages/web/src/content/docs/providers.mdx
+++ b/packages/web/src/content/docs/providers.mdx
@@ -1316,6 +1316,33 @@ To use Kimi K2 from Moonshot AI:
---
+### Mistral AI
+
+1. Head over to the [Mistral AI console](https://console.mistral.ai/), create an account, and generate an API key.
+
+2. Run the `/connect` command and search for **Mistral AI**.
+
+ ```txt
+ /connect
+ ```
+
+3. Enter your Mistral API key.
+
+ ```txt
+ ┌ API key
+ │
+ │
+ └ enter
+ ```
+
+4. Run the `/models` command to select a model like _Mistral Medium_.
+
+ ```txt
+ /models
+ ```
+
+---
+
### Nebius Token Factory
1. Head over to the [Nebius Token Factory console](https://tokenfactory.nebius.com/), create an account, and click **Add Key**.
@@ -1550,6 +1577,74 @@ OpenCode Zen is a list of tested and verified models provided by the OpenCode te
---
+### LLM Gateway
+
+1. Head over to the [LLM Gateway dashboard](https://llmgateway.io/dashboard), click **Create API Key**, and copy the key.
+
+2. Run the `/connect` command and search for LLM Gateway.
+
+ ```txt
+ /connect
+ ```
+
+3. Enter the API key for the provider.
+
+ ```txt
+ ┌ API key
+ │
+ │
+ └ enter
+ ```
+
+4. Many LLM Gateway models are preloaded by default, run the `/models` command to select the one you want.
+
+ ```txt
+ /models
+ ```
+
+ You can also add additional models through your opencode config.
+
+ ```json title="opencode.json" {6}
+ {
+ "$schema": "https://opencode.ai/config.json",
+ "provider": {
+ "llmgateway": {
+ "models": {
+ "somecoolnewmodel": {}
+ }
+ }
+ }
+ }
+ ```
+
+5. You can also customize them through your opencode config. Here's an example of specifying a provider
+
+ ```json title="opencode.json"
+ {
+ "$schema": "https://opencode.ai/config.json",
+ "provider": {
+ "llmgateway": {
+ "models": {
+ "glm-4.7": {
+ "name": "GLM 4.7"
+ },
+ "gpt-5.2": {
+ "name": "GPT-5.2"
+ },
+ "gemini-2.5-pro": {
+ "name": "Gemini 2.5 Pro"
+ },
+ "claude-3-5-sonnet-20241022": {
+ "name": "Claude 3.5 Sonnet"
+ }
+ }
+ }
+ }
+ }
+ ```
+
+---
+
### SAP AI Core
SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon, Meta, Mistral, and AI21 through a unified platform.
diff --git a/packages/web/src/content/docs/pt-br/cli.mdx b/packages/web/src/content/docs/pt-br/cli.mdx
index 32c50d7c0..78190b3c5 100644
--- a/packages/web/src/content/docs/pt-br/cli.mdx
+++ b/packages/web/src/content/docs/pt-br/cli.mdx
@@ -573,7 +573,6 @@ O opencode pode ser configurado usando variáveis de ambiente.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Desabilitar carregamento de `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Desabilitar busca de modelos de fontes remotas |
| `OPENCODE_FAKE_VCS` | string | Provedor VCS falso para fins de teste |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Desabilitar verificação de tempo de arquivo para otimização |
| `OPENCODE_CLIENT` | string | Identificador do cliente (padrão é `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Habilitar ferramentas de busca web Exa |
| `OPENCODE_SERVER_PASSWORD` | string | Habilitar autenticação básica para `serve`/`web` |
diff --git a/packages/web/src/content/docs/ru/cli.mdx b/packages/web/src/content/docs/ru/cli.mdx
index a98111530..f5aeee256 100644
--- a/packages/web/src/content/docs/ru/cli.mdx
+++ b/packages/web/src/content/docs/ru/cli.mdx
@@ -574,7 +574,6 @@ opencode можно настроить с помощью переменных с
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | логическое значение | Отключить загрузку `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | логическое значение | Отключить получение моделей из удаленных источников |
| `OPENCODE_FAKE_VCS` | строка | Поддельный поставщик VCS для целей тестирования |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | логическое значение | Отключить проверку времени файла для оптимизации |
| `OPENCODE_CLIENT` | строка | Идентификатор клиента (по умолчанию `cli`) |
| `OPENCODE_ENABLE_EXA` | логическое значение | Включить инструменты веб-поиска Exa |
| `OPENCODE_SERVER_PASSWORD` | строка | Включить базовую аутентификацию для `serve`/`web` |
diff --git a/packages/web/src/content/docs/th/cli.mdx b/packages/web/src/content/docs/th/cli.mdx
index 2f75a96a7..4b2db9d98 100644
--- a/packages/web/src/content/docs/th/cli.mdx
+++ b/packages/web/src/content/docs/th/cli.mdx
@@ -575,7 +575,6 @@ OpenCode สามารถกำหนดค่าโดยใช้ตัว�
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | Boolean | ปิดใช้งานการนำเข้า `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | Boolean | ปิดใช้งานการดึงรายการโมเดลจากระยะไกล |
| `OPENCODE_FAKE_VCS` | String | เปิดใช้งาน VCS จำลองสำหรับการทดสอบ |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | Boolean | ปิดใช้งานการตรวจสอบเวลาแก้ไขไฟล์ |
| `OPENCODE_CLIENT` | String | ตัวระบุไคลเอนต์ (ค่าเริ่มต้นคือ `cli`) |
| `OPENCODE_ENABLE_EXA` | Boolean | เปิดใช้งานการใช้ Exa แทน ls หากมี |
| `OPENCODE_SERVER_PASSWORD` | String | รหัสผ่านสำหรับการตรวจสอบสิทธิ์พื้นฐาน `serve`/`web` |
diff --git a/packages/web/src/content/docs/tr/cli.mdx b/packages/web/src/content/docs/tr/cli.mdx
index 41600b5bf..75ecca992 100644
--- a/packages/web/src/content/docs/tr/cli.mdx
+++ b/packages/web/src/content/docs/tr/cli.mdx
@@ -574,7 +574,6 @@ opencode ortam değişkenleri kullanılarak yapılandırılabilir.
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | `.claude/skills` yüklemesini devre dışı bırak |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Uzak kaynaklardan model getirmeyi devre dışı bırakın |
| `OPENCODE_FAKE_VCS` | string | Test amaçlı sahte VCS sağlayıcısı |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Optimizasyon için dosya süresi kontrolünü devre dışı bırakın |
| `OPENCODE_CLIENT` | string | Client kimliği (varsayılan: `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Exa web arama araçlarını etkinleştir |
| `OPENCODE_SERVER_PASSWORD` | string | `serve`/`web` için temel kimlik doğrulamayı etkinleştirin |
diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx
index 0c54d3d7b..c0cff134a 100644
--- a/packages/web/src/content/docs/zh-cn/cli.mdx
+++ b/packages/web/src/content/docs/zh-cn/cli.mdx
@@ -574,7 +574,6 @@ OpenCode 可以通过环境变量进行配置。
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | 禁用加载 `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 禁用从远程源获取模型 |
| `OPENCODE_FAKE_VCS` | string | 用于测试目的的模拟 VCS 提供商 |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | 禁用文件时间检查优化 |
| `OPENCODE_CLIENT` | string | 客户端标识符(默认为 `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | 启用 Exa 网络搜索工具 |
| `OPENCODE_SERVER_PASSWORD` | string | 为 `serve`/`web` 启用基本认证 |
diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx
index 5de2b9637..4df9d13fd 100644
--- a/packages/web/src/content/docs/zh-tw/cli.mdx
+++ b/packages/web/src/content/docs/zh-tw/cli.mdx
@@ -574,7 +574,6 @@ OpenCode 可以透過環境變數進行設定。
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | 停用載入 `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | 停用從遠端來源擷取模型 |
| `OPENCODE_FAKE_VCS` | string | 用於測試目的的模擬 VCS 供應商 |
-| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | 停用檔案時間檢查最佳化 |
| `OPENCODE_CLIENT` | string | 用戶端識別碼(預設為 `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | 啟用 Exa 網路搜尋工具 |
| `OPENCODE_SERVER_PASSWORD` | string | 為 `serve`/`web` 啟用基本認證 |
diff --git a/script/publish.ts b/script/publish.ts
index ac0af9c4e..4d90bd968 100755
--- a/script/publish.ts
+++ b/script/publish.ts
@@ -6,53 +6,69 @@ import { fileURLToPath } from "url"
console.log("=== publishing ===\n")
+const dir = fileURLToPath(new URL("..", import.meta.url))
+process.chdir(dir)
+const tag = `v${Script.version}`
+
const pkgjsons = await Array.fromAsync(
new Bun.Glob("**/package.json").scan({
absolute: true,
}),
).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist")))
-for (const file of pkgjsons) {
- let pkg = await Bun.file(file).text()
- pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`)
- console.log("updated:", file)
- await Bun.file(file).write(pkg)
-}
-
-const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url))
-let toml = await Bun.file(extensionToml).text()
-toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`)
-toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`)
-console.log("updated:", extensionToml)
-await Bun.file(extensionToml).write(toml)
-
-await $`bun install`
-await import(`../packages/sdk/js/script/build.ts`)
-
-if (Script.release) {
- if (!Script.preview) {
- await $`git commit -am "release: v${Script.version}"`
- await $`git tag v${Script.version}`
- await $`git fetch origin`
- await $`git cherry-pick HEAD..origin/dev`.nothrow()
- await $`git push origin HEAD --tags --no-verify --force-with-lease`
- await new Promise((resolve) => setTimeout(resolve, 5_000))
+async function prepareReleaseFiles() {
+ for (const file of pkgjsons) {
+ let pkg = await Bun.file(file).text()
+ pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`)
+ console.log("updated:", file)
+ await Bun.file(file).write(pkg)
}
- await import(`../packages/desktop/scripts/finalize-latest-json.ts`)
- await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`)
+ const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url))
+ let toml = await Bun.file(extensionToml).text()
+ toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`)
+ toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`)
+ console.log("updated:", extensionToml)
+ await Bun.file(extensionToml).write(toml)
+
+ await $`bun install`
+ await $`./packages/sdk/js/script/build.ts`
+}
- await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}`
+if (Script.release && !Script.preview) {
+ await $`git fetch origin --tags`
+ await $`git switch --detach`
}
+await prepareReleaseFiles()
+
console.log("\n=== cli ===\n")
-await import(`../packages/opencode/script/publish.ts`)
+await $`bun ./packages/opencode/script/publish.ts`
console.log("\n=== sdk ===\n")
-await import(`../packages/sdk/js/script/publish.ts`)
+await $`bun ./packages/sdk/js/script/publish.ts`
console.log("\n=== plugin ===\n")
-await import(`../packages/plugin/script/publish.ts`)
+await $`bun ./packages/plugin/script/publish.ts`
-const dir = fileURLToPath(new URL("..", import.meta.url))
-process.chdir(dir)
+if (Script.release) {
+ await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`
+ await $`bun ./packages/desktop-electron/scripts/finalize-latest-yml.ts`
+}
+
+if (Script.release && !Script.preview) {
+ await $`git commit -am "release: ${tag}"`
+ await $`git tag -d ${tag}`.nothrow()
+ await $`git tag ${tag}`
+ await $`git push origin refs/tags/${tag} --force-with-lease --no-verify`
+ await new Promise((resolve) => setTimeout(resolve, 5_000))
+ await $`git fetch origin`
+ await $`git checkout -B dev origin/dev`
+ await prepareReleaseFiles()
+ await $`git commit -am "sync release versions for ${tag}"`
+ await $`git push origin HEAD:dev --no-verify`
+}
+
+if (Script.release) {
+ await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}`
+}
diff --git a/script/version.ts b/script/version.ts
index 3ca4f661d..0582f49a3 100755
--- a/script/version.ts
+++ b/script/version.ts
@@ -4,9 +4,9 @@ import { Script } from "@opencode-ai/script"
import { $ } from "bun"
const output = [`version=${Script.version}`]
+const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
if (!Script.preview) {
- const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd())
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
const body = await Bun.file(file)
@@ -15,7 +15,7 @@ if (!Script.preview) {
const dir = process.env.RUNNER_TEMP ?? "/tmp"
const notesFile = `${dir}/opencode-release-notes.txt`
await Bun.write(notesFile, body)
- await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}`
+ await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}`
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
output.push(`release=${release.databaseId}`)
output.push(`tag=${release.tagName}`)
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index c499f679f..dfddfa9d0 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "1.4.7",
+ "version": "1.14.18",
"publisher": "sst-dev",
"repository": {
"type": "git",
diff --git a/sst-env.d.ts b/sst-env.d.ts
index 2a40a9f3c..e0c3665a9 100644
--- a/sst-env.d.ts
+++ b/sst-env.d.ts
@@ -168,10 +168,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
- "ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
- "type": "sst.sst.Secret"
- "value": string
- }
"ZEN_LITE_PRICE": {
"firstMonth100Coupon": string
"firstMonth50Coupon": string