summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
-rw-r--r--.dockerignore13
-rw-r--r--.env.example1
-rw-r--r--.gitignore34
-rw-r--r--CS_ARTIX_DEPLOY.md132
-rw-r--r--Dockerfile67
-rw-r--r--Dockerfile.dev74
-rw-r--r--HANDOFF.md99
-rw-r--r--UPSTREAM_CS_FUZZY_BUG.md111
-rwxr-xr-xbin/build7
-rwxr-xr-xbin/build-frontend17
-rwxr-xr-xbin/build-pkg28
-rwxr-xr-xbin/build-pkg-electron20
-rwxr-xr-xbin/build-pkg-windows38
-rwxr-xr-xbin/clean9
-rwxr-xr-xbin/copilot-auth84
-rwxr-xr-xbin/dev_secrets27
-rwxr-xr-xbin/down7
-rwxr-xr-xbin/import-credentials.ts34
-rwxr-xr-xbin/install-pkg76
-rwxr-xr-xbin/prod_secrets33
-rw-r--r--bin/seed-opencode-keys.ts28
-rwxr-xr-xbin/service105
-rwxr-xr-xbin/test10
-rwxr-xr-xbin/up79
-rwxr-xr-xbin/up-backend21
-rwxr-xr-xbin/up-frontend8
-rw-r--r--biome.json61
-rw-r--r--bun.lock1062
-rw-r--r--cc/01-tool-schema-analysis.md62
-rw-r--r--cc/02-anthropic-tool-format.md90
-rw-r--r--cc/03-recommendations.md116
-rw-r--r--cc/04-schema-debug.md40
-rw-r--r--cc/README.md38
-rw-r--r--dispatch.toml89
-rw-r--r--docker-compose.prod.yml17
-rw-r--r--docker-compose.yml52
-rw-r--r--docker/cs/fuzzy-distance.patch159
-rw-r--r--docker/cs/luau-declarations.patch32
-rwxr-xr-xdocker/entrypoint.dev.sh71
-rw-r--r--docker/entrypoint.sh8
-rw-r--r--package.json20
-rw-r--r--packages/api/package.json20
-rw-r--r--packages/api/src/agent-manager.ts2453
-rw-r--r--packages/api/src/app.ts278
-rw-r--r--packages/api/src/index.ts127
-rw-r--r--packages/api/src/permission-manager.ts103
-rw-r--r--packages/api/src/routes/agents.ts126
-rw-r--r--packages/api/src/routes/config.ts27
-rw-r--r--packages/api/src/routes/models.ts1073
-rw-r--r--packages/api/src/routes/notifications.ts88
-rw-r--r--packages/api/src/routes/skills.ts48
-rw-r--r--packages/api/src/routes/tabs.ts229
-rw-r--r--packages/api/src/types.ts2
-rw-r--r--packages/api/src/wake-scheduler.ts97
-rw-r--r--packages/api/tests/agent-manager.test.ts2142
-rw-r--r--packages/api/tests/permission-manager.test.ts99
-rw-r--r--packages/api/tests/routes.test.ts1097
-rw-r--r--packages/api/tests/wake-scheduler.test.ts98
-rw-r--r--packages/api/tsconfig.json10
-rw-r--r--packages/api/vitest.config.ts12
-rw-r--r--packages/core/package.json29
-rw-r--r--packages/core/src/agent/agent.tsbin66313 -> 0 bytes
-rw-r--r--packages/core/src/agents/index.ts11
-rw-r--r--packages/core/src/agents/loader.ts294
-rw-r--r--packages/core/src/chunks/append.ts314
-rw-r--r--packages/core/src/chunks/transform.ts271
-rw-r--r--packages/core/src/compaction/index.ts245
-rw-r--r--packages/core/src/config/index.ts9
-rw-r--r--packages/core/src/config/loader.ts226
-rw-r--r--packages/core/src/config/schema.ts239
-rw-r--r--packages/core/src/config/watcher.ts129
-rw-r--r--packages/core/src/credentials/anthropic-betas.ts24
-rw-r--r--packages/core/src/credentials/api-keys.ts80
-rw-r--r--packages/core/src/credentials/claude.ts694
-rw-r--r--packages/core/src/credentials/copilot.ts64
-rw-r--r--packages/core/src/credentials/google.ts178
-rw-r--r--packages/core/src/credentials/index.ts52
-rw-r--r--packages/core/src/credentials/opencode.ts126
-rw-r--r--packages/core/src/credentials/store.ts185
-rw-r--r--packages/core/src/db/chunks.ts246
-rw-r--r--packages/core/src/db/index.ts177
-rw-r--r--packages/core/src/db/settings.ts22
-rw-r--r--packages/core/src/db/tabs.ts250
-rw-r--r--packages/core/src/index.ts172
-rw-r--r--packages/core/src/llm/anthropic-oauth-transform.ts153
-rw-r--r--packages/core/src/llm/debug-logger.ts448
-rw-r--r--packages/core/src/llm/provider.ts180
-rw-r--r--packages/core/src/lsp/client.ts658
-rw-r--r--packages/core/src/lsp/diagnostic.ts41
-rw-r--r--packages/core/src/lsp/index.ts18
-rw-r--r--packages/core/src/lsp/language.ts72
-rw-r--r--packages/core/src/lsp/manager.ts220
-rw-r--r--packages/core/src/lsp/server.ts68
-rw-r--r--packages/core/src/models/attachments.ts151
-rw-r--r--packages/core/src/models/catalog.ts229
-rw-r--r--packages/core/src/models/index.ts24
-rw-r--r--packages/core/src/models/registry.ts86
-rw-r--r--packages/core/src/notifications/config.ts77
-rw-r--r--packages/core/src/notifications/dispatcher.ts287
-rw-r--r--packages/core/src/notifications/index.ts36
-rw-r--r--packages/core/src/notifications/ntfy.ts149
-rw-r--r--packages/core/src/notifications/types.ts108
-rw-r--r--packages/core/src/permission/evaluate.ts15
-rw-r--r--packages/core/src/permission/index.ts26
-rw-r--r--packages/core/src/permission/service.ts76
-rw-r--r--packages/core/src/permission/wildcard.ts10
-rw-r--r--packages/core/src/skills/index.ts7
-rw-r--r--packages/core/src/skills/loader.ts248
-rw-r--r--packages/core/src/skills/parser.ts60
-rw-r--r--packages/core/src/tools/bash-arity.ts48
-rw-r--r--packages/core/src/tools/key-usage.ts322
-rw-r--r--packages/core/src/tools/list-files.ts40
-rw-r--r--packages/core/src/tools/lsp.ts135
-rw-r--r--packages/core/src/tools/path-utils.ts55
-rw-r--r--packages/core/src/tools/read-file-slice.ts90
-rw-r--r--packages/core/src/tools/read-file.ts129
-rw-r--r--packages/core/src/tools/read-tab.ts95
-rw-r--r--packages/core/src/tools/registry.ts91
-rw-r--r--packages/core/src/tools/retrieve.ts71
-rw-r--r--packages/core/src/tools/run-shell.ts176
-rw-r--r--packages/core/src/tools/search-code.ts362
-rw-r--r--packages/core/src/tools/send-to-tab.ts198
-rw-r--r--packages/core/src/tools/shell-analyze.ts166
-rw-r--r--packages/core/src/tools/summon.ts447
-rw-r--r--packages/core/src/tools/task-list.ts149
-rw-r--r--packages/core/src/tools/truncate.ts142
-rw-r--r--packages/core/src/tools/web-search.ts106
-rw-r--r--packages/core/src/tools/write-file.ts66
-rw-r--r--packages/core/src/tools/youtube-transcribe.ts219
-rw-r--r--packages/core/src/types/index.ts672
-rw-r--r--packages/core/tests/agent/agent.test.ts1791
-rw-r--r--packages/core/tests/agents/loader.test.ts233
-rw-r--r--packages/core/tests/chunks/append.test.ts534
-rw-r--r--packages/core/tests/compaction/compaction.test.ts194
-rw-r--r--packages/core/tests/config/loader.test.ts176
-rw-r--r--packages/core/tests/config/lsp-schema.test.ts110
-rw-r--r--packages/core/tests/config/merge.test.ts223
-rw-r--r--packages/core/tests/config/watcher.test.ts50
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts73
-rw-r--r--packages/core/tests/db/chunks.db.test.ts336
-rw-r--r--packages/core/tests/db/chunks.test.ts179
-rw-r--r--packages/core/tests/db/rekey-chunks.db.test.ts129
-rw-r--r--packages/core/tests/db/tabs.test.ts418
-rw-r--r--packages/core/tests/fixture/lsp/fake-lsp-server.js195
-rw-r--r--packages/core/tests/llm/anthropic-oauth-transform.test.ts137
-rw-r--r--packages/core/tests/llm/provider.test.ts271
-rw-r--r--packages/core/tests/lsp/client.test.ts146
-rw-r--r--packages/core/tests/lsp/diagnostic.test.ts67
-rw-r--r--packages/core/tests/lsp/luau-lsp.smoke.test.ts63
-rw-r--r--packages/core/tests/lsp/manager.test.ts120
-rw-r--r--packages/core/tests/lsp/server.test.ts41
-rw-r--r--packages/core/tests/models/attachments.test.ts136
-rw-r--r--packages/core/tests/models/catalog.test.ts227
-rw-r--r--packages/core/tests/notifications/config.test.ts158
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts461
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts204
-rw-r--r--packages/core/tests/permission/evaluate.test.ts68
-rw-r--r--packages/core/tests/permission/service.test.ts95
-rw-r--r--packages/core/tests/permission/wildcard.test.ts50
-rw-r--r--packages/core/tests/tools/bash-arity.test.ts36
-rw-r--r--packages/core/tests/tools/key-usage.test.ts317
-rw-r--r--packages/core/tests/tools/list-files.test.ts94
-rw-r--r--packages/core/tests/tools/lsp-tool.test.ts110
-rw-r--r--packages/core/tests/tools/read-file.test.ts142
-rw-r--r--packages/core/tests/tools/read-tab.test.ts101
-rw-r--r--packages/core/tests/tools/registry.test.ts143
-rw-r--r--packages/core/tests/tools/run-shell.test.ts78
-rw-r--r--packages/core/tests/tools/search-code.test.ts511
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts185
-rw-r--r--packages/core/tests/tools/summon.test.ts349
-rw-r--r--packages/core/tests/tools/task-list.test.ts158
-rw-r--r--packages/core/tests/tools/write-file.test.ts152
-rw-r--r--packages/core/tests/types/reasoning-effort.test.ts48
-rw-r--r--packages/core/tsconfig.json10
-rw-r--r--packages/core/vitest.config.ts14
-rw-r--r--packages/frontend/electron/main.cjs33
-rw-r--r--packages/frontend/electron/preload.cjs7
-rw-r--r--packages/frontend/index.html12
-rw-r--r--packages/frontend/package.json54
-rw-r--r--packages/frontend/serve.ts68
-rw-r--r--packages/frontend/src/App.svelte347
-rw-r--r--packages/frontend/src/app.css111
-rw-r--r--packages/frontend/src/lib/attachment-tokens.ts234
-rw-r--r--packages/frontend/src/lib/cache-warm-storage.ts77
-rw-r--r--packages/frontend/src/lib/cache-warming.svelte.ts318
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte806
-rw-r--r--packages/frontend/src/lib/components/CacheRatePanel.svelte124
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte396
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte148
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte181
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte375
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte251
-rw-r--r--packages/frontend/src/lib/components/ContextWindowPanel.svelte85
-rw-r--r--packages/frontend/src/lib/components/DebugPanel.svelte35
-rw-r--r--packages/frontend/src/lib/components/Header.svelte27
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte35
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte477
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte174
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte636
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte356
-rw-r--r--packages/frontend/src/lib/components/PermissionPrompt.svelte100
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte643
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte220
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte317
-rw-r--r--packages/frontend/src/lib/components/SystemPromptPanel.svelte61
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte234
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte80
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte140
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte185
-rw-r--r--packages/frontend/src/lib/config.ts44
-rw-r--r--packages/frontend/src/lib/context-window.ts37
-rw-r--r--packages/frontend/src/lib/router.svelte.ts12
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts88
-rw-r--r--packages/frontend/src/lib/sidebar-storage.ts84
-rw-r--r--packages/frontend/src/lib/snapshot-sequencer.ts47
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts2441
-rw-r--r--packages/frontend/src/lib/theme.ts92
-rw-r--r--packages/frontend/src/lib/types.ts337
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts127
-rw-r--r--packages/frontend/src/main.ts9
-rw-r--r--packages/frontend/src/vite-env.d.ts2
-rw-r--r--packages/frontend/svelte.config.js5
-rw-r--r--packages/frontend/tests/attachment-tokens.test.ts130
-rw-r--r--packages/frontend/tests/cache-warm-storage.test.ts111
-rw-r--r--packages/frontend/tests/cache-warming.test.ts232
-rw-r--r--packages/frontend/tests/chat-store.test.ts2203
-rw-r--r--packages/frontend/tests/context-window.test.ts84
-rw-r--r--packages/frontend/tests/sidebar-storage.test.ts148
-rw-r--r--packages/frontend/tests/snapshot-sequencer.test.ts92
-rw-r--r--packages/frontend/tests/theme.test.ts144
-rw-r--r--packages/frontend/tsconfig.json10
-rw-r--r--packages/frontend/vite.config.ts23
-rw-r--r--packaging/PKGBUILD274
-rwxr-xr-xpackaging/dispatch-api-wrapper.sh32
-rw-r--r--packaging/dispatch-api.conf31
-rw-r--r--packaging/[email protected]21
-rwxr-xr-xpackaging/dispatch-frontend-wrapper.sh30
-rw-r--r--packaging/dispatch-frontend.conf21
-rw-r--r--packaging/[email protected]22
-rw-r--r--packaging/dispatch-s6.install46
-rw-r--r--packaging/dispatch-systemd.install25
-rw-r--r--packaging/dispatch.install31
-rw-r--r--packaging/electron/PKGBUILD100
-rwxr-xr-xpackaging/electron/dispatch-electron-wrapper.sh2
-rw-r--r--packaging/electron/dispatch-electron.install32
-rw-r--r--packaging/electron/dispatch.desktop9
-rw-r--r--packaging/electron/dispatch.pngbin10192 -> 0 bytes
-rw-r--r--packaging/electron/dispatch.svg24
-rw-r--r--packaging/s6/dispatch-api-log/consumer-for1
-rw-r--r--packaging/s6/dispatch-api-log/notification-fd1
-rw-r--r--packaging/s6/dispatch-api-log/pipeline-name1
-rw-r--r--packaging/s6/dispatch-api-log/run6
-rw-r--r--packaging/s6/dispatch-api-log/type1
-rw-r--r--packaging/s6/dispatch-api-srv/producer-for1
-rw-r--r--packaging/s6/dispatch-api-srv/run50
-rw-r--r--packaging/s6/dispatch-api-srv/type1
-rw-r--r--packaging/s6/dispatch-frontend-log/consumer-for1
-rw-r--r--packaging/s6/dispatch-frontend-log/notification-fd1
-rw-r--r--packaging/s6/dispatch-frontend-log/pipeline-name1
-rw-r--r--packaging/s6/dispatch-frontend-log/run6
-rw-r--r--packaging/s6/dispatch-frontend-log/type1
-rw-r--r--packaging/s6/dispatch-frontend-srv/producer-for1
-rw-r--r--packaging/s6/dispatch-frontend-srv/run25
-rw-r--r--packaging/s6/dispatch-frontend-srv/type1
-rw-r--r--research/ai-coding-assistants-established.md345
-rw-r--r--research/ai-coding-assistants-newer.md451
-rw-r--r--research/emerging-specialized.md473
-rw-r--r--research/general-agent-platforms.md299
-rw-r--r--research/multi-agent-orchestration.md235
-rw-r--r--research/multi-agent-roleplay.md439
-rw-r--r--research/pi-dev-harness.md322
-rw-r--r--tsconfig.base.json20
-rw-r--r--vitest.config.ts7
273 files changed, 0 insertions, 49043 deletions
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index bbec9fe..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,13 +0,0 @@
-node_modules/
-dist/
-build/
-.env
-*.db
-*.sqlite
-.DS_Store
-.git/
-context.md
-harness-comparison.md
-plan.md
-requirements.md
-research/
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 9a08117..0000000
--- a/.env.example
+++ /dev/null
@@ -1 +0,0 @@
-OPENCODE_API_KEY=your_opencode_go_api_key_here
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index dc485ec..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,34 +0,0 @@
-node_modules/
-dist/
-build/
-.env
-.env.dispatch
-*.db
-*.sqlite
-.DS_Store
-.skills/
-references/
-
-# Local scratch / sensitive — never commit
-cyberdeck/
-.old_tabs.svelte.ts
-
-# Packaging build artifacts
-packaging/src/
-packaging/pkg/
-packaging/*.pkg.tar.zst
-packaging/*.tar.zst
-packaging/electron/src/
-packaging/electron/pkg/
-packaging/electron/*.pkg.tar.zst
-packaging/electron/*.tar.zst
-packages/frontend/release/
-
-# Code-review artifacts
-claude-report.md
-
-# notes/ is local-only scratch (plans, reports, wishlist)
-notes/
-
-# Debug log output (DEBUG=1 bin/up writes LLM request/response + usage JSON here)
-logging/
diff --git a/CS_ARTIX_DEPLOY.md b/CS_ARTIX_DEPLOY.md
deleted file mode 100644
index a942ef8..0000000
--- a/CS_ARTIX_DEPLOY.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Deploying the `cs` / `search_code` binary to the Artix (s6) cyberdeck
-
-## TL;DR
-
-The `search_code` agent tool shells out to a `cs` (code spelunker) binary. This
-feature provisions that binary on **two** deployment paths automatically:
-
-| Path | Mechanism | `cs` ends up at |
-| --- | --- | --- |
-| `bin/up` | Docker (`Dockerfile` / `Dockerfile.dev`, `cs-builder` stage) | `/usr/local/bin/cs` |
-| `bin/service install` | native Arch package `code-search` (built by `packaging/PKGBUILD`, installed by `bin/install-pkg`) | `/usr/bin/cs` |
-
-There is a **third** path — the Artix cyberdeck box — that is deployed by a
-personal script living **outside this repo**
-(`~/projects/cyberdeck/sync-dispatch.sh`). That script has now been edited (the
-5 small additions below) so the Artix box also gets the patched `cs`. Before the
-edit, `search_code` on the cyberdeck returned its graceful
-`Error: search_code requires the 'cs' binary ...` message on every call.
-
-This file documents that follow-up (now applied). It is committed so the change
-isn't forgotten and can be re-derived if the cyberdeck script is ever reset; the
-actual edit lives in the cyberdeck repo, not here.
-
----
-
-## Why this is needed
-
-`packaging/PKGBUILD` now builds a `code-search` split package (a patched,
-statically-linked `cs` pinned to upstream commit
-`697e0bf194bbc7a4a877e5170c70618989fc92e7`, tag `v3.1.0`, plus two patches:
-`docker/cs/luau-declarations.patch` for Roblox `.luau` declaration support and
-`docker/cs/fuzzy-distance.patch` for correct fuzzy edit-distance matching). It
-installs `cs` to `/usr/bin/cs`.
-
-`code-search` is a plain static binary with **no init-system coupling**, so it
-installs and runs identically on Artix (Arch-based, `pacman`/`x86_64`). The only
-gap is that `sync-dispatch.sh` — which pushes packages to the Artix box and
-`pacman -U`s them — has a hardcoded two-package list (`dispatch` + `dispatch-s6`)
-and does not yet include `code-search`.
-
-> Note: `sync-dispatch.sh` builds and pushes packages from the **main** dispatch
-> checkout (`/home/tradam/projects/dispatch/packaging`), so this edit only
-> becomes meaningful **after this feature branch is merged to `dev`** and that
-> checkout rebuilds packages (`bin/build-pkg` / `sync-dispatch.sh --build`).
-
----
-
-## The edit applied to `~/projects/cyberdeck/sync-dispatch.sh`
-
-Five small additions (the four below plus mirroring `PKG_CS` into the generated
-remote-script preamble alongside `PKG_DISPATCH` / `PKG_S6`). This edit has been
-applied. To deploy, run `sync-dispatch.sh --build` (the `--build` flag rebuilds
-the packages first, producing the new `code-search-*.pkg.tar.zst` that now
-carries both the Luau and fuzzy patches).
-
-### 1. Declare the package name (next to `PKG_DISPATCH` / `PKG_S6`)
-
-```sh
-PKG_DISPATCH="dispatch-0.0.1-1-x86_64.pkg.tar.zst"
-PKG_S6="dispatch-s6-0.0.1-1-x86_64.pkg.tar.zst"
-PKG_CS="code-search-0.0.1-1-x86_64.pkg.tar.zst" # <-- add
-```
-
-### 2. Add it to the "package exists" pre-check loop
-
-```sh
-for pkg in "$PKG_DISPATCH" "$PKG_S6" "$PKG_CS"; do # <-- add "$PKG_CS"
- if [ ! -f "${PKG_DIR}/${pkg}" ]; then
- echo "ERROR: ${PKG_DIR}/${pkg} not found. Run with --build or 'bin/build-pkg' first." >&2
- exit 1
- fi
-done
-```
-
-### 3. Add it to the `scp` upload
-
-```sh
-scp -q "${PKG_DIR}/${PKG_DISPATCH}" "${PKG_DIR}/${PKG_S6}" "${PKG_DIR}/${PKG_CS}" "${TARGET}:/tmp/"
-# ^^^^^^^^^^^^^^^^^^^^^^^ add
-```
-
-### 4. Add it to the remote `pacman -U` and cleanup `rm`
-
-Inside the remote script heredoc:
-
-```sh
-pacman -U --noconfirm "/tmp/$PKG_DISPATCH" "/tmp/$PKG_S6" "/tmp/$PKG_CS"
-rm -f "/tmp/$PKG_DISPATCH" "/tmp/$PKG_S6" "/tmp/$PKG_CS"
-```
-
-> The remote script is generated inside `sync-dispatch.sh` and references
-> `$PKG_CS` via the same variable-expansion mechanism already used for
-> `$PKG_DISPATCH` / `$PKG_S6`. Make sure `PKG_CS` is exported/substituted into
-> the remote script the same way those two are (search the script for every
-> place `PKG_S6` appears and mirror it for `PKG_CS`).
-
-No s6 service changes are needed — `code-search` ships only a binary, not a
-service, so the existing `s6 repository sync` / `s6 set enable` dance is
-unaffected.
-
----
-
-## Verifying on the Artix box after sync
-
-```sh
-cs --version # -> cs version 3.1.0
-which cs # -> /usr/bin/cs
-pacman -Q code-search # -> code-search 0.0.1-1
-```
-
-Then, in a Dispatch tab with the `search_code` permission enabled, run a search;
-it should return ranked results instead of the "cs binary not found" error.
-
-For a `.luau` sanity check (confirms the Luau patch is present), search a Roblox
-project with `only: "declarations"` — `function` / `type` / `export type` lines
-should be detected.
-
-For a fuzzy sanity check (confirms the fuzzy patch is present), a mid-word
-deletion should match, e.g. `cs -- 'computSlipAngle~1'` finds `computeSlipAngle`
-(returns empty on an unpatched cs).
-
----
-
-## If you ever decouple `cs` from this repo
-
-`code-search` is intentionally a standalone package (own name, own
-`/usr/bin/cs`, upstream MIT license shipped). If `cs` later graduates to its own
-AUR/repo package, the cleaner end state is to drop `package_code-search()` from
-`packaging/PKGBUILD` and instead declare a `depends=('code-search')` (or the AUR
-name) on the `dispatch` package — but as of this writing **no official or AUR
-package for boyter/cs exists** (the AUR `cs` is an unrelated `ls`-with-icons
-tool), so building it here is the correct approach.
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 27a9f1d..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,67 +0,0 @@
-# Production Dockerfile — multi-stage build for the API server only
-# Frontend deploys separately (e.g., Cloudflare Pages)
-
-# --- cs (code spelunker) builder ---
-# Builds a patched, statically-linked `cs` binary for the search_code tool.
-# Pinned to the v3.1.0 commit for reproducibility; the patch adds Luau
-# declaration support and corrects fuzzy edit-distance matching (see
-# docker/cs/luau-declarations.patch and docker/cs/fuzzy-distance.patch). cs vendors its
-# dependencies, so the `go build` step is offline after the clone.
-FROM golang:1.25-bookworm AS cs-builder
-ARG CS_COMMIT=697e0bf194bbc7a4a877e5170c70618989fc92e7
-WORKDIR /build
-COPY docker/cs/luau-declarations.patch /tmp/luau-declarations.patch
-COPY docker/cs/fuzzy-distance.patch /tmp/fuzzy-distance.patch
-RUN git clone https://github.com/boyter/cs.git src \
- && cd src \
- && git checkout "${CS_COMMIT}" \
- && git apply /tmp/luau-declarations.patch \
- && git apply /tmp/fuzzy-distance.patch \
- && CGO_ENABLED=0 go build -mod=vendor -ldflags="-s -w" -o /usr/local/bin/cs . \
- && /usr/local/bin/cs --version
-
-FROM oven/bun:1 AS builder
-
-WORKDIR /app
-
-COPY package.json bun.lock ./
-COPY packages/core/package.json packages/core/package.json
-COPY packages/api/package.json packages/api/package.json
-COPY packages/frontend/package.json packages/frontend/package.json
-
-RUN bun install --frozen-lockfile
-
-COPY . .
-
-# --- Production image ---
-
-FROM oven/bun:1
-
-WORKDIR /app
-
-# Copy only what the API server needs
-COPY --from=builder /app/package.json ./
-COPY --from=builder /app/node_modules ./node_modules
-COPY --from=builder /app/packages/core ./packages/core
-COPY --from=builder /app/packages/api ./packages/api
-
-# Bundle the patched `cs` code-search binary for the search_code tool
-COPY --from=cs-builder /usr/local/bin/cs /usr/local/bin/cs
-
-# Create workspace directory for file tools
-RUN mkdir -p workspace
-
-# Production entrypoint
-COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
-RUN chmod +x /usr/local/bin/entrypoint.sh
-
-# Run as non-root
-USER bun
-
-EXPOSE 3000
-
-HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
- CMD bun -e "const r = await fetch('http://localhost:3000/health'); process.exit(r.ok ? 0 : 1)"
-
-ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
-CMD ["bun", "packages/api/src/index.ts"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
deleted file mode 100644
index 9614564..0000000
--- a/Dockerfile.dev
+++ /dev/null
@@ -1,74 +0,0 @@
-# --- cs (code spelunker) builder ---
-# Builds a patched, statically-linked `cs` binary for the search_code tool.
-# Pinned to the v3.1.0 commit for reproducibility; the patch adds Luau
-# declaration support and corrects fuzzy edit-distance matching (see
-# docker/cs/luau-declarations.patch and docker/cs/fuzzy-distance.patch). cs vendors its
-# dependencies, so the `go build` step is offline after the clone.
-FROM golang:1.25-bookworm AS cs-builder
-ARG CS_COMMIT=697e0bf194bbc7a4a877e5170c70618989fc92e7
-WORKDIR /build
-COPY docker/cs/luau-declarations.patch /tmp/luau-declarations.patch
-COPY docker/cs/fuzzy-distance.patch /tmp/fuzzy-distance.patch
-RUN git clone https://github.com/boyter/cs.git src \
- && cd src \
- && git checkout "${CS_COMMIT}" \
- && git apply /tmp/luau-declarations.patch \
- && git apply /tmp/fuzzy-distance.patch \
- && CGO_ENABLED=0 go build -mod=vendor -ldflags="-s -w" -o /usr/local/bin/cs . \
- && /usr/local/bin/cs --version
-
-FROM oven/bun:1
-
-WORKDIR /app
-
-# ─── Roblox Luau tooling (luau-lsp + rojo) ──────────────────────
-# The LSP test bed under references/stunt-olympics/ drives Dispatch's
-# luau-lsp integration: diagnostics-on-write and the `lsp` tool. Both
-# `luau-lsp` and `rojo` must be on PATH inside the container (luau-lsp's
-# sourcemap.autogenerate shells out to `rojo sourcemap --watch`). The base
-# oven/bun image ships neither curl nor unzip, so install those first.
-#
-# Versions are pinned for reproducible builds; bump via --build-arg.
-ARG LUAU_LSP_VERSION=1.68.0
-ARG ROJO_VERSION=7.6.1
-RUN set -eux; \
- apt-get update; \
- apt-get install -y --no-install-recommends curl unzip ca-certificates; \
- rm -rf /var/lib/apt/lists/*; \
- arch="$(uname -m)"; \
- case "$arch" in \
- x86_64) luau_asset="luau-lsp-linux-x86_64.zip"; rojo_asset="rojo-${ROJO_VERSION}-linux-x86_64.zip" ;; \
- aarch64) luau_asset="luau-lsp-linux-arm64.zip"; rojo_asset="rojo-${ROJO_VERSION}-linux-aarch64.zip" ;; \
- *) echo "Unsupported arch: $arch" >&2; exit 1 ;; \
- esac; \
- tmp="$(mktemp -d)"; \
- curl -fsSL "https://github.com/JohnnyMorganz/luau-lsp/releases/download/${LUAU_LSP_VERSION}/${luau_asset}" -o "$tmp/luau-lsp.zip"; \
- unzip -q "$tmp/luau-lsp.zip" -d "$tmp/luau-lsp"; \
- install -m 0755 "$tmp/luau-lsp/luau-lsp" /usr/local/bin/luau-lsp; \
- curl -fsSL "https://github.com/rojo-rbx/rojo/releases/download/v${ROJO_VERSION}/${rojo_asset}" -o "$tmp/rojo.zip"; \
- unzip -q "$tmp/rojo.zip" -d "$tmp/rojo"; \
- install -m 0755 "$tmp/rojo/rojo" /usr/local/bin/rojo; \
- rm -rf "$tmp"; \
- luau-lsp --version; \
- rojo --version
-
-# Copy dependency files for layer caching
-COPY package.json bun.lock ./
-COPY packages/core/package.json packages/core/package.json
-COPY packages/api/package.json packages/api/package.json
-COPY packages/frontend/package.json packages/frontend/package.json
-
-# Install dependencies (cached unless package files change)
-RUN bun install
-
-# Bundle the patched `cs` code-search binary for the search_code tool
-COPY --from=cs-builder /usr/local/bin/cs /usr/local/bin/cs
-
-# Source code is volume-mounted at runtime, overriding this copy
-COPY . .
-
-# Dev entrypoint: re-runs bun install to pick up dependency changes, then exec's the command
-COPY docker/entrypoint.dev.sh /usr/local/bin/entrypoint.sh
-RUN chmod +x /usr/local/bin/entrypoint.sh
-
-ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
diff --git a/HANDOFF.md b/HANDOFF.md
deleted file mode 100644
index cfaf89d..0000000
--- a/HANDOFF.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Handoff — perm/fix-user-agent-summon-permission
-
-## Summary
-Fixed a permissions bug: granting **only** the user-agent (top-level) permission
-(`perm_user_agent`) without the subagent-summon permission (`perm_summon`) left
-the agent unable to summon user agents. The whole `summon` tool was gated behind
-`perm_summon`, so `perm_user_agent` alone produced no summon tool at all.
-
-The two permissions are now fully independent in **both** directions:
-- **`perm_summon` only** → spawn ordinary subagents (unchanged; no `top_level`).
-- **`perm_user_agent` only** → `summon` is registered in *user-agent-only* mode:
- it spawns **only** top-level user agents (`top_level` forced on; the
- `top_level`/`background` params are dropped; the catalog lists user agents only;
- `retrieve` is NOT granted since user agents are fire-and-forget). This prevents
- the inverse leak (a user-agent-only grant cannot spawn plain subagents).
-- **both** → full behavior, byte-for-byte identical to before.
-- **neither** → no `summon` tool (unchanged).
-
-## Root cause
-`packages/api/src/agent-manager.ts`, parent tool-build path: `if (permSummon) { … }`
-built the entire `summon` (+`retrieve`) tool. `perm_user_agent` only flipped the
-`userAgentEnabled` flag *inside* that block, so without `perm_summon` the tool was
-never created.
-
-## Files changed
-- `packages/core/src/tools/summon.ts`
- - `createSummonTool(...)` gained a trailing `subagentEnabled = true` param
- (mirrors `perm_summon`) alongside `userAgentEnabled` (mirrors `perm_user_agent`).
- Default `true` keeps every existing call site / mock behaving as before.
- - New internal `userAgentOnly = userAgentEnabled && !subagentEnabled` mode:
- description leads with user-agent spawning and omits subagent/parallel-work
- prose; `top_level` and `background` params are omitted; `execute()` forces
- `topLevel: true`; `agent` param lists only user-agent slugs.
- - `buildAgentsCatalog(...)` gained a `subagentEnabled` param and a user-agent-only
- branch ("User agents (spawned as independent top-level tabs):", no
- `requires top_level=true` suffix since it is implied).
-- `packages/api/src/agent-manager.ts`
- - Parent path: `if (permSummon)` → `if (permSummon || permUserAgent)`.
- - Passes `permSummon` as the new `subagentEnabled` arg to `createSummonTool`.
- - `retrieve` is now only registered when `permSummon` is granted (bundled with
- the subagent capability; user agents are fire-and-forget).
- - Child/subagent path (`toolsOverride`, whitelist-driven) left untouched — out of
- scope per agreement.
-- `packages/core/tests/tools/summon.test.ts`
- - New `user-agent-only mode` describe block (description content, catalog groups,
- `agent` slug list, omitted `top_level`/`background` params, forced
- `topLevel: true` on spawn).
- - New regression block asserting the `subagentEnabled` default keeps legacy
- subagent spawning unchanged.
-- `packages/api/tests/agent-manager.test.ts`
- - New `summon / user_agent permission split` describe block: summon+retrieve when
- only `perm_summon`; **summon WITHOUT retrieve** when only `perm_user_agent`
- (the bug-fix regression); both → summon+retrieve; neither → neither.
- - `@dispatch/core` test mock gained `loadAgents`, `toAvailableSubagents`,
- `toAvailableUserAgents`, `getAgentDirPaths`, `GLOBAL_AGENTS_DIR` (the summon
- parent-branch was never exercised before, so these were missing).
-
-## Public surface changed
-- `createSummonTool(defaultWorkingDirectory, callbacks, availableSubagents?,
- availableUserAgents?, agentDirs?, userAgentEnabled?, subagentEnabled?)` — added a
- final optional `subagentEnabled` param (default `true`). Backward compatible:
- all existing callers omit it and keep prior behavior.
-- No DB/schema/migration changes; both settings (`perm_summon`, `perm_user_agent`)
- already existed. No frontend changes (the "Spawn user agents" checkbox and
- independent `perm_user_agent` persistence already existed).
-
-## Verification (post-merge with `dev`, all green)
-- `bun run test` → **605 passed** (37 files). +15 net new tests on this branch
- (the +9 over the pre-merge 596 are from `dev`'s send_to_tab/read_tab prompt suite).
-- `bun run check` (biome) → clean, "No fixes applied."
-- `bun run --cwd packages/core typecheck` → clean.
-- `bun run --cwd packages/api typecheck` → clean.
-- `bun run --cwd packages/frontend typecheck` → 0 errors, 0 warnings.
-
-## User test
-Confirmed by the user: with only "Spawn user agents" granted (Summon agents OFF),
-the agent receives the `summon` tool and can spawn a top-level user agent. ✅
-
-## Published
-Yes. Merged `dev` down into `perm/fix-user-agent-summon-permission` (resolved one
-test-file conflict where this branch's new describe block and `dev`'s new
-send_to_tab/read_tab system-prompt block landed at the same location — kept both),
-re-ran all verification (green), and fast-forwarded:
-`git push . HEAD:dev` → `e0b63c0..a243976 HEAD -> dev`.
-
-Commits:
-- `3ff2db6` fix(perm): decouple perm_user_agent from perm_summon for spawning user agents
-- `a243976` Merge branch 'dev' into perm/fix-user-agent-summon-permission
-
-## Assumptions / known gaps
-- **Child/nested summon path unchanged** (per agreement #3): a spawned subagent gets
- `summon` only if `"summon"` is in its tool whitelist, and `userAgentEnabled` there
- still tracks the `perm_user_agent` DB setting. Decoupling nested user-agent
- spawning was deliberately out of scope.
-- **`hasSummon` system-prompt note** (agent-manager ~line 163) still says "You have
- pre-configured subagent types… delegate to a subagent." In user-agent-only mode
- this wording is slightly off, but the `summon` tool's own (mode-correct)
- description carries the authoritative instructions. Left as-is to limit scope —
- flag if you want it tailored.
diff --git a/UPSTREAM_CS_FUZZY_BUG.md b/UPSTREAM_CS_FUZZY_BUG.md
deleted file mode 100644
index a177be3..0000000
--- a/UPSTREAM_CS_FUZZY_BUG.md
+++ /dev/null
@@ -1,111 +0,0 @@
-# Upstream bug: `cs` fuzzy search only matches substitutions, not insertions/deletions
-
-> Drafted for a potential PR/issue to [boyter/cs](https://github.com/boyter/cs).
-> Dispatch ships a local fix as `docker/cs/fuzzy-distance.patch` (applied to the
-> pinned cs build); this document is the upstream-facing writeup.
-
-## Summary
-
-`cs`'s fuzzy operator `term~N` is documented as *"fuzzy match within 1 or 2
-distance"* (`cs --help`, README), i.e. Levenshtein edit distance. In practice it
-only ever matches **substitutions**: a mid-word **insertion** or **deletion**
-that is genuinely edit distance 1 does **not** match.
-
-- `computSlipAngle~1` does **not** find `computeSlipAngle` (one dropped `e`) — edit distance 1.
-- `houss~1` finds `house` (works — it's a substitution), but `hose~1` does **not** find `house` (one inserted `u`) — edit distance 1.
-
-Affected version: `cs version 3.1.0` (observed at commit `697e0bf`). The defect
-is not reported in the issue tracker (checked all 51 open/closed issues + PRs as
-of this writing).
-
-## Root cause
-
-`pkg/search/executor.go` implements fuzzy matching by scanning only
-**same-length** windows of the content. For a term of length `L` it compares
-every `L`-character substring against the term:
-
-```go
-// fuzzyContains checks if any same-length substring of content matches
-// the term within the given edit distance (substitution-based matching).
-func fuzzyContains(content, term string, maxDist, termLen int) bool {
- contentLen := len(content)
- if contentLen == 0 || termLen == 0 || termLen > contentLen {
- return false
- }
- for i := 0; i <= contentLen-termLen; i++ {
- window := content[i : i+termLen] // <-- always exactly termLen long
- if levenshtein(window, term) <= maxDist {
- return true
- }
- }
- return false
-}
-```
-
-`fuzzyFind` (which produces match locations) has the same structure. Because
-every candidate `window` is exactly `termLen` characters, the only edit the
-comparison can ever observe is a **substitution**. An insertion or deletion
-changes the string length by one, so the matching substring of the content is
-`termLen ± 1` long and is never formed — hence never compared.
-
-This also makes one of the existing tests assert buggy behavior:
-`{"Fuzzy Distance 2", "hovze~2", ...}` expects to match only `house`'s 5-char
-window, but `hovze` is also within distance 2 of the 4-char window `" ove"`
-elsewhere in the corpus — a match the same-length scan structurally cannot see.
-
-## Fix
-
-Scan windows of **every plausible length** in
-`[termLen - maxDist, termLen + maxDist]` (clamped to ≥ 1) at each offset, and
-keep the best (lowest-distance) window. A Levenshtein distance of `d` requires
-the two strings' lengths to differ by at most `d` (each insert/delete shifts
-length by one), so this range is exactly the set of window lengths that can be
-within `maxDist`. `fuzzyFind` records the best window at each start and then
-advances past it, so a single logical match doesn't produce a swarm of
-overlapping locations.
-
-See `docker/cs/fuzzy-distance.patch` for the full diff. Shape:
-
-```go
-func fuzzyWindowBounds(termLen, maxDist int) (int, int) {
- minLen := termLen - maxDist
- if minLen < 1 {
- minLen = 1
- }
- return minLen, termLen + maxDist
-}
-
-// bestFuzzyMatchAt returns the length of the lowest-distance window starting at
-// i that is within maxDist of term, or -1. Ties prefer the length closest to
-// termLen.
-func bestFuzzyMatchAt(content, term string, i, maxDist, minLen, maxLen int) int { ... }
-```
-
-## Complexity note
-
-Worst-case work per offset goes from one `levenshtein` call to `2*maxDist + 1`
-calls. Since `cs` only supports `~1` and `~2`, that's a small constant factor
-(≤ 5×) and only on fuzzy queries, which are rare relative to keyword/regex
-searches. No measurable impact in practice.
-
-## Verification
-
-The change keeps `go test ./pkg/search/... ./pkg/ranker/... ./...` green (with
-the one buggy assertion corrected) and adds explicit mid-word insertion/deletion
-cases:
-
-```go
-{"Fuzzy Distance 2", "houze~2", false, []string{"src/main/file1.go"}},
-{"Fuzzy Distance 1 deletion", "hose~1", false, []string{"src/main/file1.go"}}, // hose -> house (insert 'u')
-{"Fuzzy Distance 1 insertion", "houxse~1", false, []string{"src/main/file1.go"}}, // houxse -> house (delete 'x')
-```
-
-End-to-end against a real `.luau` codebase, all four of these now match (they
-returned empty before):
-
-```
-computSlipAngle~1 -> computeSlipAngle (CarPhysics.luau)
-LauncTuning~1 -> LaunchTuning (LaunchController.luau)
-LaunchTunin~1 -> LaunchTuning (LaunchTuning.luau) # already worked (suffix)
-TireFrictio~1 -> TireFriction (CarPhysics.luau) # already worked (suffix)
-```
diff --git a/bin/build b/bin/build
deleted file mode 100755
index cd0dabb..0000000
--- a/bin/build
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-docker compose -f "$PROJECT_DIR/docker-compose.yml" build "$@"
diff --git a/bin/build-frontend b/bin/build-frontend
deleted file mode 100755
index 703e072..0000000
--- a/bin/build-frontend
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Build frontend assets inside a temporary container
-# Output lands in packages/frontend/dist/ on the host (via bind mount)
-#
-# VITE_API_URL can be set to configure the API endpoint for the build.
-# Example: VITE_API_URL=https://api.example.com bin/build-frontend
-
-sudo VITE_API_URL="${VITE_API_URL:-}" \
- docker compose -f "$PROJECT_DIR/docker-compose.yml" \
- run --rm \
- -e VITE_API_URL \
- frontend bun run --cwd packages/frontend build "$@"
diff --git a/bin/build-pkg b/bin/build-pkg
deleted file mode 100755
index f8bc8b2..0000000
--- a/bin/build-pkg
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env bash
-# Build the dispatch Arch split package: dispatch + dispatch-systemd + dispatch-s6.
-# One makepkg run produces three .pkg.tar.zst files in packaging/.
-#
-# Usage:
-# bin/build-pkg # build
-# bin/build-pkg --noconfirm # forward extra args to makepkg
-#
-# Override frontend build target with VITE_API_URL, e.g.:
-# VITE_API_URL="https://api.example.com" bin/build-pkg
-#
-# The patched `cs` code-search binary is reused from an already-installed
-# code-search package instead of being recompiled. Force a fresh clone+build
-# (required after bumping _cs_commit in the PKGBUILD) with:
-# DISPATCH_FORCE_CS_BUILD=1 bin/build-pkg
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-PACKAGING_DIR="$PROJECT_DIR/packaging"
-
-cd "$PACKAGING_DIR"
-makepkg -fd "$@"
-
-echo ""
-echo "Built packages (newest first):"
-ls -1t "$PACKAGING_DIR"/*.pkg.tar.zst 2>/dev/null | head -5
diff --git a/bin/build-pkg-electron b/bin/build-pkg-electron
deleted file mode 100755
index ceec321..0000000
--- a/bin/build-pkg-electron
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/bin/env bash
-# Build the dispatch-electron Arch package (Linux desktop wrapper).
-# Depends on the `dispatch` package being built/installed at the matching version.
-#
-# Usage:
-# bin/build-pkg-electron # build
-# bin/build-pkg-electron --noconfirm
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-PACKAGING_DIR="$PROJECT_DIR/packaging/electron"
-
-cd "$PACKAGING_DIR"
-makepkg -fd "$@"
-
-echo ""
-echo "Built package:"
-ls -1t "$PACKAGING_DIR"/*.pkg.tar.zst 2>/dev/null | head -1
diff --git a/bin/build-pkg-windows b/bin/build-pkg-windows
deleted file mode 100755
index c5760e0..0000000
--- a/bin/build-pkg-windows
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env bash
-# Build the Windows Electron output via electron-builder.
-# Produces an unpacked directory at packages/frontend/release/win-unpacked.
-#
-# Usage:
-# bin/build-pkg-windows
-#
-# Override the API URL the frontend bundle points to:
-# VITE_API_URL="http://your-host:18390" bin/build-pkg-windows
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-FRONTEND_DIR="$PROJECT_DIR/packages/frontend"
-VITE_API_URL="${VITE_API_URL:-http://localhost:18390}"
-
-cd "$PROJECT_DIR"
-
-# Install deps (electron + electron-builder live as frontend devDependencies)
-bun install
-
-# Build the SPA bundle, then package it for Windows (unpacked dir)
-cd "$FRONTEND_DIR"
-VITE_API_URL="$VITE_API_URL" bun run dist:win
-
-WIN_BUILD="$FRONTEND_DIR/release/win-unpacked"
-
-if [ ! -d "$WIN_BUILD" ]; then
- echo "Build failed: no win-unpacked directory found at $WIN_BUILD" >&2
- exit 1
-fi
-
-echo ""
-echo "Windows Electron build ready at:"
-echo " $WIN_BUILD"
-echo ""
-echo "Copy that folder to a Windows machine and run Dispatch.exe."
diff --git a/bin/clean b/bin/clean
deleted file mode 100755
index 76bdbe1..0000000
--- a/bin/clean
+++ /dev/null
@@ -1,9 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Stop containers, remove volumes, remove images, and clear build cache
-sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" down --volumes --rmi local "$@"
-sudo docker builder prune -f --filter "label=com.docker.compose.project=dispatch"
diff --git a/bin/copilot-auth b/bin/copilot-auth
deleted file mode 100755
index 28d08a1..0000000
--- a/bin/copilot-auth
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-CLIENT_ID="Ov23li8tweQw6odWQebz"
-ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env.dispatch"
-
-# Parse form-encoded key=value pairs
-parse() {
- local key="$1" data="$2"
- local val
- val=$(echo "$data" | sed -n "s/.*${key}=\([^&]*\).*/\1/p")
- python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))" <<< "$val"
-}
-
-echo "=== GitHub Copilot OAuth ==="
-echo ""
-
-# Step 1: Request device code
-echo "Requesting device code..."
-RESP=$(curl -s https://github.com/login/device/code \
- -d "client_id=$CLIENT_ID" \
- -d "scope=read:user")
-
-DEVICE_CODE=$(parse "device_code" "$RESP")
-
-if [ -z "$DEVICE_CODE" ]; then
- echo "Failed to get device code: $RESP"
- exit 1
-fi
-
-USER_CODE=$(parse "user_code" "$RESP")
-VERIF_URI=$(parse "verification_uri" "$RESP")
-INTERVAL=$(parse "interval" "$RESP")
-INTERVAL=${INTERVAL:-5}
-
-echo ""
-echo "Open this URL and enter the code:"
-echo " $VERIF_URI"
-echo " Code: $USER_CODE"
-echo ""
-echo "Waiting for you to authorize..."
-
-# Step 2: Poll for access token
-TOKEN=""
-ATTEMPTS=0
-while [ -z "$TOKEN" ] && [ $ATTEMPTS -lt 60 ]; do
- sleep "$INTERVAL"
- ATTEMPTS=$((ATTEMPTS + 1))
-
- TOKEN_RESP=$(curl -s https://github.com/login/oauth/access_token \
- -d "client_id=$CLIENT_ID" \
- -d "device_code=$DEVICE_CODE" \
- -d "grant_type=urn:ietf:params:oauth:grant-type:device_code")
-
- TOKEN=$(parse "access_token" "$TOKEN_RESP" || true)
- ERROR=$(parse "error" "$TOKEN_RESP" || true)
-
- if [ "$ERROR" = "authorization_pending" ]; then
- continue
- elif [ "$ERROR" = "slow_down" ]; then
- INTERVAL=$((INTERVAL + 5))
- continue
- elif [ -n "$ERROR" ]; then
- echo "Error: $ERROR"
- exit 1
- fi
-done
-
-if [ -z "$TOKEN" ]; then
- echo "Timed out waiting for authorization."
- exit 1
-fi
-
-# Step 3: Write to .env.dispatch
-mkdir -p "$(dirname "$ENV_FILE")"
-if [ -f "$ENV_FILE" ] && grep -q "^COPILOT_TOKEN=" "$ENV_FILE" 2>/dev/null; then
- sed -i "s/^COPILOT_TOKEN=.*/COPILOT_TOKEN=$TOKEN/" "$ENV_FILE"
-else
- echo "COPILOT_TOKEN=$TOKEN" >> "$ENV_FILE"
-fi
-
-echo ""
-echo "Token saved to .env.dispatch"
-echo "Ready — run: docker compose up"
diff --git a/bin/dev_secrets b/bin/dev_secrets
deleted file mode 100755
index 98af50d..0000000
--- a/bin/dev_secrets
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Force GPG to use terminal-based pinentry (required for SSH sessions)
-export GPG_TTY=$(tty)
-
-echo "Checking dev secrets for Dispatch..."
-echo ""
-
-# --- OpenCode Go API Key ---
-
-if gopass show -o projects/ai-api/opencode_go_key &>/dev/null; then
- echo "[ok] OpenCode Go API key exists"
-else
- echo "OpenCode Go API key not found in gopass."
- echo ""
- echo " 1. Go to https://opencode.ai/auth"
- echo " 2. Sign in and copy your API key"
- echo " 3. Paste it below"
- echo ""
- read -rp "Enter your OpenCode Go API key: " OPENCODE_KEY
- echo "$OPENCODE_KEY" | gopass insert -f projects/ai-api/opencode_go_key
- echo "[ok] OpenCode Go API key stored"
-fi
-
-echo ""
-echo "All dev secrets are configured."
diff --git a/bin/down b/bin/down
deleted file mode 100755
index 9188ca2..0000000
--- a/bin/down
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-docker compose -f "$PROJECT_DIR/docker-compose.yml" down "$@"
diff --git a/bin/import-credentials.ts b/bin/import-credentials.ts
deleted file mode 100755
index a4ea499..0000000
--- a/bin/import-credentials.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Import Claude credentials into the Dispatch SQLite database.
- * Reads from ~/.claude/.credentials-{1,2}.json and stores them
- * for the configured keys (claude-pro, claude-max).
- */
-import { getDatabasePath, importCredentialsFromFile } from "../packages/core/src/index.js";
-
-const imports = [
- {
- keyId: "claude-pro",
- provider: "anthropic",
- file: `${process.env.HOME}/.claude/.credentials-1.json`,
- },
- {
- keyId: "claude-max",
- provider: "anthropic",
- file: `${process.env.HOME}/.claude/.credentials-2.json`,
- },
-];
-
-console.log(`Database: ${getDatabasePath()}\n`);
-
-for (const { keyId, provider, file } of imports) {
- process.stdout.write(`Importing ${keyId} from ${file} ... `);
- const result = importCredentialsFromFile(keyId, provider, file);
- if (result.success) {
- console.log("OK");
- } else {
- console.log(`FAILED: ${result.error}`);
- }
-}
-
-console.log("\nDone.");
diff --git a/bin/install-pkg b/bin/install-pkg
deleted file mode 100755
index 9af784d..0000000
--- a/bin/install-pkg
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env bash
-# Install one or more built dispatch packages with yay -U.
-#
-# Default: installs the freshest dispatch + dispatch-systemd from packaging/.
-# Pass package names (without version) to install a custom set.
-#
-# Usage:
-# bin/install-pkg # dispatch + dispatch-systemd + code-search
-# bin/install-pkg dispatch dispatch-s6 # dispatch + dispatch-s6
-# bin/install-pkg dispatch dispatch-electron # dispatch + electron wrapper
-# bin/install-pkg --all # every freshest pkg found
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-PACKAGING_DIR="$PROJECT_DIR/packaging"
-ELECTRON_DIR="$PROJECT_DIR/packaging/electron"
-
-# Find the most recent .pkg.tar.zst matching a package name in a given dir.
-find_pkg() {
- local name="$1" dir="$2"
- ls -t "$dir"/"$name"-[0-9]*-x86_64.pkg.tar.zst 2>/dev/null | head -1
-}
-
-# Resolve a package name to its freshest tarball, searching both dirs.
-resolve_pkg() {
- local name="$1" path
- path=$(find_pkg "$name" "$PACKAGING_DIR")
- [ -n "$path" ] || path=$(find_pkg "$name" "$ELECTRON_DIR")
- echo "$path"
-}
-
-declare -a names
-declare -a paths
-
-if [ $# -eq 0 ]; then
- names=(dispatch dispatch-systemd)
- # code-search ships a self-contained `cs` binary pinned to _cs_commit in the
- # PKGBUILD; it rarely changes. Skip reinstalling it if it's already present —
- # run `bin/install-pkg code-search` to force a reinstall (e.g. after a bump).
- if pacman -Qq code-search &>/dev/null; then
- echo "code-search already installed — skipping (run 'bin/install-pkg code-search' to force)"
- else
- names+=(code-search)
- fi
-elif [ "${1:-}" = "--all" ]; then
- names=(dispatch dispatch-systemd dispatch-s6 dispatch-electron code-search)
-else
- names=("$@")
-fi
-
-for name in "${names[@]}"; do
- path=$(resolve_pkg "$name")
- if [ -z "$path" ]; then
- # --all is lenient: skip missing packages
- if [ "${1:-}" = "--all" ]; then
- echo "warn: no built package found for '$name', skipping" >&2
- continue
- fi
- echo "error: no built package found for '$name'" >&2
- echo " run bin/build-pkg (or bin/build-pkg-electron) first." >&2
- exit 1
- fi
- paths+=("$path")
-done
-
-if [ ${#paths[@]} -eq 0 ]; then
- echo "error: nothing to install." >&2
- exit 1
-fi
-
-echo "Installing:"
-printf ' %s\n' "${paths[@]}"
-echo ""
-yay -U "${paths[@]}"
diff --git a/bin/prod_secrets b/bin/prod_secrets
deleted file mode 100755
index 17e35c2..0000000
--- a/bin/prod_secrets
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Force GPG to use terminal-based pinentry (required for SSH sessions)
-export GPG_TTY=$(tty)
-
-echo "Checking production secrets for Dispatch..." >&2
-echo "" >&2
-
-# --- OpenCode Go API Key (shared across environments) ---
-
-if gopass show -o projects/ai-api/opencode_go_key &>/dev/null; then
- echo "[ok] OpenCode Go API key exists" >&2
-else
- echo "OpenCode Go API key not found in gopass." >&2
- echo "" >&2
- echo " 1. Go to https://opencode.ai/auth" >&2
- echo " 2. Sign in and copy your API key" >&2
- echo " 3. Paste it below" >&2
- echo "" >&2
- read -rp "Enter your OpenCode Go API key: " OPENCODE_KEY
- echo "$OPENCODE_KEY" | gopass insert -f projects/ai-api/opencode_go_key
- echo "[ok] OpenCode Go API key stored" >&2
-fi
-
-echo "" >&2
-echo "All production secrets are configured. Outputting .env:" >&2
-echo "" >&2
-
-# --- Output .env format to stdout ---
-
-echo "OPENCODE_API_KEY=$(gopass show -o projects/ai-api/opencode_go_key)"
-echo "DISPATCH_MODEL=deepseek-v4-flash"
diff --git a/bin/seed-opencode-keys.ts b/bin/seed-opencode-keys.ts
deleted file mode 100644
index c481b8d..0000000
--- a/bin/seed-opencode-keys.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Seed API keys from environment variables into the SQLite database.
- */
-import { getDatabasePath, setApiKey } from "../packages/core/src/index.js";
-
-console.log(`Database: ${getDatabasePath()}\n`);
-
-const keys = [
- { keyId: "opencode-1", provider: "opencode-go", envVar: "OPENCODE_KEY_1" },
- { keyId: "opencode-2", provider: "opencode-go", envVar: "OPENCODE_KEY_2" },
- { keyId: "copilot", provider: "github-copilot", envVar: "COPILOT_TOKEN" },
- { keyId: "opencode-cookie", provider: "opencode-go", envVar: "OPENCODE_COOKIE" },
- { keyId: "opencode-ws1", provider: "opencode-go", envVar: "OPENCODE_WS1_ID" },
- { keyId: "opencode-ws2", provider: "opencode-go", envVar: "OPENCODE_WS2_ID" },
-];
-
-for (const { keyId, provider, envVar } of keys) {
- const value = process.env[envVar];
- if (value) {
- setApiKey(keyId, provider, value);
- console.log(`${keyId}: imported from $${envVar}`);
- } else {
- console.log(`${keyId}: SKIPPED ($${envVar} not set)`);
- }
-}
-
-console.log("\nDone.");
diff --git a/bin/service b/bin/service
deleted file mode 100755
index 62471b9..0000000
--- a/bin/service
+++ /dev/null
@@ -1,105 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Dispatch runs as system service instances keyed on the *invoking* user, so
-# `bin/service start` brings the services up running as you (e.g. dispatch-api@tradam).
-RUN_USER="$(id -un)"
-API_UNIT="dispatch-api@${RUN_USER}"
-FRONTEND_UNIT="dispatch-frontend@${RUN_USER}"
-
-cmd="${1:-help}"
-shift 2>/dev/null || true
-
-usage() {
- cat <<EOF
-Usage: bin/service <command>
-
-Commands:
- install Install the dispatch packages (systemd system unit templates +
- application files in /opt/dispatch).
- start Start dispatch-api and dispatch-frontend as ${RUN_USER}.
- If already running, restart instead.
- Prints the frontend URL when ready.
- stop Stop dispatch-api and dispatch-frontend.
- status Show status of both services.
- logs Tail journal output for both services.
-
-Services run as system instances for the current user:
- ${API_UNIT}
- ${FRONTEND_UNIT}
-Start/stop use sudo (system manager). After install, configure
-/etc/dispatch/dispatch-api.conf before starting.
-EOF
-}
-
-install() {
- echo "==> Building fresh package..."
- "$PROJECT_DIR/bin/build-pkg"
-
- echo ""
- echo "==> Installing..."
- "$PROJECT_DIR/bin/install-pkg"
-
- sudo systemctl daemon-reload
- echo ""
- echo "==> Installed. Edit /etc/dispatch/dispatch-api.conf, then run:"
- echo " bin/service start"
-}
-
-have_units() {
- systemctl cat [email protected] &>/dev/null
-}
-
-start() {
- if ! have_units; then
- echo "dispatch services not installed. Run: bin/service install"
- return 1
- fi
- local api_active frontend_active
- api_active=$(systemctl is-active "$API_UNIT" 2>/dev/null || echo "inactive")
- frontend_active=$(systemctl is-active "$FRONTEND_UNIT" 2>/dev/null || echo "inactive")
-
- if [ "$api_active" = "active" ] || [ "$frontend_active" = "active" ]; then
- echo "==> Services already running (api=$api_active frontend=$frontend_active) — restarting"
- sudo systemctl restart "$API_UNIT" "$FRONTEND_UNIT"
- echo "dispatch-api + dispatch-frontend restarted (as ${RUN_USER})"
- else
- sudo systemctl start "$API_UNIT" "$FRONTEND_UNIT"
- echo "dispatch-api + dispatch-frontend started (as ${RUN_USER})"
- fi
- echo " → http://localhost:18391"
-}
-
-stop() {
- if ! have_units; then
- echo "dispatch services not installed."
- return 1
- fi
- sudo systemctl stop "$API_UNIT" "$FRONTEND_UNIT"
- echo "dispatch-api + dispatch-frontend stopped"
-}
-
-status() {
- if ! have_units; then
- echo "dispatch services not installed."
- return 1
- fi
- systemctl status "$API_UNIT" "$FRONTEND_UNIT" --no-pager 2>/dev/null || true
-}
-
-logs() {
- sudo journalctl -u "$API_UNIT" -u "$FRONTEND_UNIT" -f "$@"
-}
-
-case "$cmd" in
- install) install ;;
- start) start ;;
- stop) stop ;;
- status) status ;;
- logs) logs "$@" ;;
- help|--help|-h) usage ;;
- *) echo "Unknown command: $cmd"; echo; usage; exit 1 ;;
-esac
diff --git a/bin/test b/bin/test
deleted file mode 100755
index 61b31ce..0000000
--- a/bin/test
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Run the test suite inside the API container
-# Uses the api service since it has access to the full workspace
-sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" \
- run --rm api bun run test "$@"
diff --git a/bin/up b/bin/up
deleted file mode 100755
index a47135c..0000000
--- a/bin/up
+++ /dev/null
@@ -1,79 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Pass host user identity so the container runs as the same UID/GID
-export HOST_UID="$(id -u)"
-export HOST_GID="$(id -g)"
-export HOST_USER="$(whoami)"
-
-# --- DEBUG=1 convenience flag ------------------------------------
-# `DEBUG=1 bin/up` turns on full LLM debug logging (request/response
-# bodies) AND the per-step usage split (cache read/write tokens), and
-# routes the JSON log files into `logging/` at the project root. That
-# directory is mounted into the container at /app/logging via the
-# `.:/app` bind, so the files land on the host where you can
-# `tail -F logging/*.json` without `docker exec`. `logging/` is
-# gitignored.
-#
-# It only sets DEFAULTS -- any DISPATCH_DEBUG_* var you set explicitly
-# still wins, so fine-grained control (e.g. a custom verbosity or a
-# different log dir) is preserved.
-if [ -n "${DEBUG:-}" ] && [ "$DEBUG" != "0" ]; then
- : "${DISPATCH_DEBUG_LLM:=1}"
- : "${DISPATCH_DEBUG_USAGE:=1}"
- : "${DISPATCH_DEBUG_LLM_DIR:=/app/logging}"
-fi
-
-# Debug-logger pass-through. docker-compose only forwards env vars that are
-# (a) set in the parent shell AND (b) referenced in docker-compose.yml's
-# `environment:` block -- so without this `export` step the variables would
-# be invisible to the container even when the user prefixes the command with
-# DISPATCH_DEBUG_LLM=1. We re-export here (rather than relying on the shell's
-# inline `VAR=... cmd` syntax) so it works whether the user sets them inline,
-# in their shell rc, or via `.env`.
-#
-# All variables default to empty -- when unset, the logger short-circuits and
-# does nothing.
-export DISPATCH_DEBUG_LLM="${DISPATCH_DEBUG_LLM:-}"
-export DISPATCH_DEBUG_LLM_VERBOSITY="${DISPATCH_DEBUG_LLM_VERBOSITY:-}"
-export DISPATCH_DEBUG_USAGE="${DISPATCH_DEBUG_USAGE:-}"
-# Container-side log directory. Empty => the logger uses its built-in default
-# (/tmp/dispatch/llm-debug). DEBUG=1 sets it to /app/logging (see above).
-export DISPATCH_DEBUG_LLM_DIR="${DISPATCH_DEBUG_LLM_DIR:-}"
-
-# Pre-create the debug log directory owned by the host user. Without this,
-# docker auto-creates the bind-mount source as root on first start, and the
-# container's bun process (running as host UID) then gets EACCES on every log
-# write -- silent except for `[dispatch-debug] Failed to write ...: EACCES`
-# lines drowned in stderr.
-#
-# The host path depends on which container dir the logger targets:
-# - /app/<sub> -> the project bind-mount, host path $PROJECT_DIR/<sub>
-# (already host-owned; a plain mkdir is enough).
-# - otherwise -> the dedicated /tmp/dispatch/llm-debug volume; may have
-# been root-created by a prior run, so fix ownership.
-case "${DISPATCH_DEBUG_LLM_DIR}" in
- /app/*)
- HOST_LOG_DIR="$PROJECT_DIR/${DISPATCH_DEBUG_LLM_DIR#/app/}"
- mkdir -p "$HOST_LOG_DIR" 2>/dev/null || true
- ;;
- *)
- HOST_LOG_DIR=/tmp/dispatch/llm-debug
- mkdir -p "$HOST_LOG_DIR" 2>/dev/null || true
- if [ ! -O "$HOST_LOG_DIR" ]; then
- current_owner=$(stat -c '%U' "$HOST_LOG_DIR" 2>/dev/null || echo "unknown")
- echo "bin/up: $HOST_LOG_DIR is owned by '$current_owner', fixing ownership to '$HOST_USER'..."
- sudo chown -R "$HOST_UID:$HOST_GID" "$HOST_LOG_DIR"
- fi
- ;;
-esac
-
-if [ -n "${DISPATCH_DEBUG_LLM}" ]; then
- echo "bin/up: debug logging ON -> ${DISPATCH_DEBUG_LLM_DIR:-/tmp/dispatch/llm-debug} (host: $HOST_LOG_DIR)"
-fi
-
-# Start all services
-docker compose -f "$PROJECT_DIR/docker-compose.yml" up "$@"
diff --git a/bin/up-backend b/bin/up-backend
deleted file mode 100755
index 894fc01..0000000
--- a/bin/up-backend
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Force GPG to use terminal-based pinentry (required for SSH sessions)
-export GPG_TTY=$(tty)
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Load secrets from gopass
-OPENCODE_API_KEY="$(gopass show -o projects/ai-api/opencode_go_key)"
-
-# Pass host user identity so the container runs as the same UID/GID
-export HOST_UID="$(id -u)"
-export HOST_GID="$(id -g)"
-export HOST_USER="$(whoami)"
-
-# Start API service only
-sudo -E OPENCODE_API_KEY="$OPENCODE_API_KEY" \
- HOST_UID="$HOST_UID" HOST_GID="$HOST_GID" HOST_USER="$HOST_USER" \
- docker compose -f "$PROJECT_DIR/docker-compose.yml" up api "$@"
diff --git a/bin/up-frontend b/bin/up-frontend
deleted file mode 100755
index 82b4523..0000000
--- a/bin/up-frontend
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Frontend has no secrets — start frontend service only
-sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" up frontend "$@"
diff --git a/biome.json b/biome.json
deleted file mode 100644
index af4f574..0000000
--- a/biome.json
+++ /dev/null
@@ -1,61 +0,0 @@
-{
- "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
- "assist": { "actions": { "source": { "organizeImports": "on" } } },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": true
- }
- },
- "formatter": {
- "enabled": true,
- "indentStyle": "tab",
- "lineWidth": 100
- },
- "javascript": {
- "formatter": {
- "quoteStyle": "double",
- "semicolons": "always"
- }
- },
- "css": {
- "parser": {
- "tailwindDirectives": true
- }
- },
- "overrides": [
- {
- "includes": ["**/*.css"],
- "linter": {
- "rules": {
- "correctness": {
- "noUnknownProperty": "off"
- }
- }
- }
- },
- {
- "includes": ["**/*.svelte"],
- "linter": {
- "rules": {
- "correctness": {
- "noUnusedImports": "off",
- "noUnusedVariables": "off"
- }
- }
- }
- }
- ],
- "files": {
- "includes": [
- "**",
- "!**/node_modules",
- "!**/dist",
- "!**/build",
- "!references",
- "!roblox-opencode-config-sample",
- "!packaging",
- "!**/release"
- ]
- }
-}
diff --git a/bun.lock b/bun.lock
deleted file mode 100644
index b92950e..0000000
--- a/bun.lock
+++ /dev/null
@@ -1,1062 +0,0 @@
-{
- "lockfileVersion": 1,
- "configVersion": 1,
- "workspaces": {
- "": {
- "name": "dispatch",
- "devDependencies": {
- "@biomejs/biome": "^2.4.15",
- "typescript": "^5.7.0",
- "vitest": "^3.0.0",
- },
- },
- "packages/api": {
- "name": "@dispatch/api",
- "version": "0.0.1",
- "dependencies": {
- "@dispatch/core": "workspace:*",
- "hono": "^4.0.0",
- },
- "devDependencies": {
- "@types/bun": "latest",
- },
- },
- "packages/core": {
- "name": "@dispatch/core",
- "version": "0.0.1",
- "dependencies": {
- "@ai-sdk/anthropic": "^3.0.79",
- "@ai-sdk/openai-compatible": "^2.0.48",
- "ai": "^6.0.191",
- "chokidar": "^5.0.0",
- "smol-toml": "^1.6.1",
- "tree-sitter-bash": "^0.25.1",
- "vscode-jsonrpc": "8.2.1",
- "vscode-languageserver-types": "3.17.5",
- "web-tree-sitter": "^0.26.8",
- "zod": "^3.23.0",
- "zod-to-json-schema": "^3.25.2",
- },
- "devDependencies": {
- "@types/bun": "latest",
- },
- },
- "packages/frontend": {
- "name": "@dispatch/frontend",
- "version": "0.0.1",
- "dependencies": {
- "@dispatch/core": "workspace:*",
- "dompurify": "^3.4.5",
- "highlight.js": "^11.11.1",
- "marked": "^18.0.4",
- "marked-highlight": "^2.2.4",
- "phosphor-svelte": "^3.1.0",
- "svelte": "^5.0.0",
- },
- "devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^5.0.0",
- "@tailwindcss/vite": "^4.0.0",
- "@types/dompurify": "^3.2.0",
- "daisyui": "^5.0.0",
- "electron": "^35.0.0",
- "electron-builder": "^26.0.0",
- "svelte-check": "^4.0.0",
- "tailwindcss": "^4.0.0",
- "vite": "^6.0.0",
- },
- },
- },
- "packages": {
- "7zip-bin": ["[email protected]", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
-
- "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-saEX+h5JDOkT9P/+REKDyikbnJiToFuLipgNcsmu4Zr3GW5kW1m9HhvrPK+vj63itIOsoZU6tmVIjkrePOlIUA=="],
-
- "@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MYKAeD2q7/sa1ZdqtL2tw0Me0B8Tok6Q/fhkJDhJl39dG8u+VBlWO9yk9lcdm784bM418o1EKObo4aOxs6+18Q=="],
-
- "@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q=="],
-
- "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
-
- "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
-
- "@biomejs/biome": ["@biomejs/[email protected]", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
-
- "@biomejs/cli-darwin-arm64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
-
- "@biomejs/cli-darwin-x64": ["@biomejs/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="],
-
- "@biomejs/cli-linux-arm64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="],
-
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="],
-
- "@biomejs/cli-linux-x64": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="],
-
- "@biomejs/cli-linux-x64-musl": ["@biomejs/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="],
-
- "@biomejs/cli-win32-arm64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="],
-
- "@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
-
- "@develar/schema-utils": ["@develar/[email protected]", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
-
- "@dispatch/api": ["@dispatch/api@workspace:packages/api"],
-
- "@dispatch/core": ["@dispatch/core@workspace:packages/core"],
-
- "@dispatch/frontend": ["@dispatch/frontend@workspace:packages/frontend"],
-
- "@electron/asar": ["@electron/[email protected]", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
-
- "@electron/fuses": ["@electron/[email protected]", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
-
- "@electron/get": ["@electron/[email protected]", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
-
- "@electron/notarize": ["@electron/[email protected]", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
-
- "@electron/osx-sign": ["@electron/[email protected]", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
-
- "@electron/rebuild": ["@electron/[email protected]", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg=="],
-
- "@electron/universal": ["@electron/[email protected]", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
-
- "@electron/windows-sign": ["@electron/[email protected]", "", { "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", "fs-extra": "^11.1.1", "minimist": "^1.2.8", "postject": "^1.0.0-alpha.6" }, "bin": { "electron-windows-sign": "bin/electron-windows-sign.js" } }, "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ=="],
-
- "@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
-
- "@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
-
- "@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
-
- "@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
-
- "@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
-
- "@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
-
- "@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
-
- "@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
-
- "@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
-
- "@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
-
- "@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
-
- "@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
-
- "@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
-
- "@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
-
- "@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
-
- "@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
-
- "@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
-
- "@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
-
- "@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
-
- "@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
-
- "@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
-
- "@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
-
- "@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
-
- "@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
-
- "@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
-
- "@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
-
- "@isaacs/fs-minipass": ["@isaacs/[email protected]", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
-
- "@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
-
- "@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
-
- "@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
-
- "@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
-
- "@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
-
- "@malept/cross-spawn-promise": ["@malept/[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
-
- "@malept/flatpak-bundler": ["@malept/[email protected]", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
-
- "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
-
- "@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="],
-
- "@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="],
-
- "@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="],
-
- "@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="],
-
- "@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="],
-
- "@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="],
-
- "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="],
-
- "@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="],
-
- "@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="],
-
- "@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="],
-
- "@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="],
-
- "@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="],
-
- "@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="],
-
- "@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="],
-
- "@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="],
-
- "@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="],
-
- "@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="],
-
- "@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="],
-
- "@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="],
-
- "@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="],
-
- "@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="],
-
- "@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="],
-
- "@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="],
-
- "@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="],
-
- "@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
-
- "@sindresorhus/is": ["@sindresorhus/[email protected]", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
-
- "@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
-
- "@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
-
- "@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
-
- "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/[email protected]", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
-
- "@szmarczak/http-timer": ["@szmarczak/[email protected]", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
-
- "@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
-
- "@tailwindcss/oxide": ["@tailwindcss/[email protected]", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
-
- "@tailwindcss/oxide-android-arm64": ["@tailwindcss/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
-
- "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
-
- "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
-
- "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
-
- "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
-
- "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
-
- "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
-
- "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
-
- "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
-
- "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/[email protected]", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
-
- "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
-
- "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
-
- "@tailwindcss/vite": ["@tailwindcss/[email protected]", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
-
- "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
-
- "@types/cacheable-request": ["@types/[email protected]", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
-
- "@types/chai": ["@types/[email protected]", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
-
- "@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
-
- "@types/deep-eql": ["@types/[email protected]", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
-
- "@types/dompurify": ["@types/[email protected]", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
-
- "@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
-
- "@types/fs-extra": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
-
- "@types/http-cache-semantics": ["@types/[email protected]", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
-
- "@types/keyv": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
-
- "@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
-
- "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="],
-
- "@types/plist": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
-
- "@types/responselike": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
-
- "@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
-
- "@types/verror": ["@types/[email protected]", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
-
- "@types/yauzl": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
-
- "@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
-
- "@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
-
- "@vitest/mocker": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
-
- "@vitest/pretty-format": ["@vitest/[email protected]", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
-
- "@vitest/runner": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
-
- "@vitest/snapshot": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
-
- "@vitest/spy": ["@vitest/[email protected]", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
-
- "@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
-
- "@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
-
- "abbrev": ["[email protected]", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
-
- "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
-
- "agent-base": ["[email protected]", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
-
- "ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/gateway": "3.0.120", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zAxvjKebQE7YkSyyNIl0OM7i6/zygnKeF+yNUjD4nWOelYrG+LpDd6RnH6mjySI4zUpZ7o4wbnmAy8jc6u98vQ=="],
-
- "ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
-
- "ajv-keywords": ["[email protected]", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
-
- "ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
- "ansi-styles": ["[email protected]", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
-
- "app-builder-bin": ["[email protected]", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
-
- "app-builder-lib": ["[email protected]", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
-
- "argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
-
- "aria-query": ["[email protected]", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
-
- "assert-plus": ["[email protected]", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
-
- "assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
-
- "astral-regex": ["[email protected]", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
-
- "async": ["[email protected]", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
-
- "async-exit-hook": ["[email protected]", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
-
- "asynckit": ["[email protected]", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
-
- "at-least-node": ["[email protected]", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
-
- "axobject-query": ["[email protected]", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
-
- "balanced-match": ["[email protected]", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
-
- "base64-js": ["[email protected]", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
-
- "boolean": ["[email protected]", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
-
- "brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
-
- "buffer": ["[email protected]", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
-
- "buffer-crc32": ["[email protected]", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
-
- "buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
-
- "builder-util": ["[email protected]", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
-
- "builder-util-runtime": ["[email protected]", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
-
- "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
-
- "cac": ["[email protected]", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
-
- "cacheable-lookup": ["[email protected]", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
-
- "cacheable-request": ["[email protected]", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
-
- "call-bind-apply-helpers": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
-
- "chai": ["[email protected]", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
-
- "chalk": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
-
- "check-error": ["[email protected]", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
-
- "chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
-
- "chownr": ["[email protected]", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
-
- "chromium-pickle-js": ["[email protected]", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
-
- "ci-info": ["[email protected]", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
-
- "cli-truncate": ["[email protected]", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
-
- "cliui": ["[email protected]", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
-
- "clone-response": ["[email protected]", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
-
- "clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
-
- "color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
-
- "color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
-
- "combined-stream": ["[email protected]", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
-
- "commander": ["[email protected]", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
-
- "compare-version": ["[email protected]", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="],
-
- "concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
-
- "core-util-is": ["[email protected]", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
-
- "crc": ["[email protected]", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
-
- "cross-dirname": ["[email protected]", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
-
- "cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
-
- "daisyui": ["[email protected]", "", {}, "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ=="],
-
- "debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
-
- "decompress-response": ["[email protected]", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
-
- "deep-eql": ["[email protected]", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
-
- "deepmerge": ["[email protected]", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
-
- "defer-to-connect": ["[email protected]", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
-
- "define-data-property": ["[email protected]", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
-
- "define-properties": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
-
- "delayed-stream": ["[email protected]", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
-
- "detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
-
- "detect-node": ["[email protected]", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
-
- "devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
-
- "dir-compare": ["[email protected]", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
-
- "dmg-builder": ["[email protected]", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
-
- "dmg-license": ["[email protected]", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
-
- "dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
-
- "dotenv": ["[email protected]", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
-
- "dotenv-expand": ["[email protected]", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
-
- "dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
-
- "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": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw=="],
-
- "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=="],
-
- "electron-builder-squirrel-windows": ["[email protected]", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
-
- "electron-publish": ["[email protected]", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
-
- "electron-winstaller": ["[email protected]", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
-
- "emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
-
- "end-of-stream": ["[email protected]", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
-
- "enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-wE4fDO8OjJhrPFH69HUQStq5oKvGRTNXEyW+k5C/pUQLASSsTu7obd2V3GvCDgPcY9AWjhJ4jz9Kh7iRvrxhJg=="],
-
- "env-paths": ["[email protected]", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
-
- "err-code": ["[email protected]", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
-
- "es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
-
- "es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
-
- "es-module-lexer": ["[email protected]", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
-
- "es-object-atoms": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
-
- "es-set-tostringtag": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
-
- "es6-error": ["[email protected]", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
-
- "esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
-
- "escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
-
- "escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
-
- "esm-env": ["[email protected]", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
-
- "esrap": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="],
-
- "estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
-
- "eventsource-parser": ["[email protected]", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
-
- "expect-type": ["[email protected]", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
-
- "exponential-backoff": ["[email protected]", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
-
- "extract-zip": ["[email protected]", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
-
- "extsprintf": ["[email protected]", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
-
- "fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
-
- "fast-json-stable-stringify": ["[email protected]", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
-
- "fd-slicer": ["[email protected]", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
-
- "fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
-
- "filelist": ["[email protected]", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
-
- "form-data": ["[email protected]", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
-
- "fs-extra": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
-
- "fs.realpath": ["[email protected]", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
-
- "fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
-
- "function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
-
- "get-caller-file": ["[email protected]", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
-
- "get-intrinsic": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
-
- "get-proto": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
-
- "get-stream": ["[email protected]", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
-
- "glob": ["[email protected]", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
-
- "global-agent": ["[email protected]", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
-
- "globalthis": ["[email protected]", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
-
- "gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
-
- "got": ["[email protected]", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="],
-
- "graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
-
- "has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
-
- "has-property-descriptors": ["[email protected]", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
-
- "has-symbols": ["[email protected]", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
-
- "has-tostringtag": ["[email protected]", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
-
- "hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
-
- "highlight.js": ["[email protected]", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
-
- "hono": ["[email protected]", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
-
- "hosted-git-info": ["[email protected]", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
-
- "http-cache-semantics": ["[email protected]", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
-
- "http-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
-
- "http2-wrapper": ["[email protected]", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
-
- "https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
-
- "iconv-corefoundation": ["[email protected]", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
-
- "iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
-
- "ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
-
- "inflight": ["[email protected]", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
-
- "inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
-
- "is-fullwidth-code-point": ["[email protected]", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
-
- "is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
-
- "isbinaryfile": ["[email protected]", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
-
- "isexe": ["[email protected]", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
-
- "jake": ["[email protected]", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
-
- "jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
-
- "js-tokens": ["[email protected]", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
-
- "js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
-
- "json-buffer": ["[email protected]", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
-
- "json-schema": ["[email protected]", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
-
- "json-schema-traverse": ["[email protected]", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
-
- "json-stringify-safe": ["[email protected]", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
-
- "json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
-
- "jsonfile": ["[email protected]", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
-
- "keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
-
- "kleur": ["[email protected]", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
-
- "lazy-val": ["[email protected]", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
-
- "lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
-
- "lightningcss-android-arm64": ["[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
-
- "lightningcss-darwin-arm64": ["[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
-
- "lightningcss-darwin-x64": ["[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
-
- "lightningcss-freebsd-x64": ["[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
-
- "lightningcss-linux-arm-gnueabihf": ["[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
-
- "lightningcss-linux-arm64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
-
- "lightningcss-linux-arm64-musl": ["[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
-
- "lightningcss-linux-x64-gnu": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
-
- "lightningcss-linux-x64-musl": ["[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
-
- "lightningcss-win32-arm64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
-
- "lightningcss-win32-x64-msvc": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
-
- "locate-character": ["[email protected]", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
-
- "lodash": ["[email protected]", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
-
- "loupe": ["[email protected]", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
-
- "lowercase-keys": ["[email protected]", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
-
- "lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
-
- "magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
-
- "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
-
- "marked-highlight": ["[email protected]", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="],
-
- "matcher": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
-
- "math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
-
- "mime": ["[email protected]", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
-
- "mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
-
- "mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
-
- "mimic-response": ["[email protected]", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
-
- "minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
-
- "minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
-
- "minipass": ["[email protected]", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
-
- "minizlib": ["[email protected]", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
-
- "mkdirp": ["[email protected]", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
-
- "mri": ["[email protected]", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
-
- "ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
-
- "nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
-
- "node-abi": ["[email protected]", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="],
-
- "node-addon-api": ["[email protected]", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
-
- "node-api-version": ["[email protected]", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
-
- "node-gyp": ["[email protected]", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
-
- "node-gyp-build": ["[email protected]", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
-
- "nopt": ["[email protected]", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
-
- "normalize-url": ["[email protected]", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
-
- "object-keys": ["[email protected]", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
-
- "once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
-
- "p-cancelable": ["[email protected]", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
-
- "p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
-
- "path-is-absolute": ["[email protected]", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
-
- "path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
-
- "pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
-
- "pathval": ["[email protected]", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
-
- "pe-library": ["[email protected]", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
-
- "pend": ["[email protected]", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
-
- "phosphor-svelte": ["[email protected]", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.13" }, "peerDependencies": { "svelte": "^5.0.0 || ^5.0.0-next.96", "vite": ">=5" }, "optionalPeers": ["vite"] }, "sha512-nldtxx+XCgNREvrb7O5xgDsefytXpSkPTx8Rnu3f2qQCUZLDV1rLxYSd2Jcwckuo9lZB1qKMqGR17P4UDC0PrA=="],
-
- "picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
-
- "picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
-
- "plist": ["[email protected]", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
-
- "postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
-
- "postject": ["[email protected]", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
-
- "proc-log": ["[email protected]", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
-
- "progress": ["[email protected]", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
-
- "promise-retry": ["[email protected]", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
-
- "proper-lockfile": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
-
- "pump": ["[email protected]", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
-
- "punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
-
- "quick-lru": ["[email protected]", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
-
- "read-binary-file-arch": ["[email protected]", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
-
- "readdirp": ["[email protected]", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
-
- "require-directory": ["[email protected]", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
-
- "resedit": ["[email protected]", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
-
- "resolve-alpn": ["[email protected]", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
-
- "responselike": ["[email protected]", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
-
- "retry": ["[email protected]", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
-
- "rimraf": ["[email protected]", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
-
- "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.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],
-
- "sade": ["[email protected]", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
-
- "safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
-
- "sanitize-filename": ["[email protected]", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
-
- "sax": ["[email protected]", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
-
- "semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
-
- "semver-compare": ["[email protected]", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
-
- "serialize-error": ["[email protected]", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
-
- "shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
-
- "shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
-
- "siginfo": ["[email protected]", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
-
- "signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
-
- "simple-update-notifier": ["[email protected]", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
-
- "slice-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
-
- "smart-buffer": ["[email protected]", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
-
- "smol-toml": ["[email protected]", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
-
- "source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
-
- "source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
-
- "source-map-support": ["[email protected]", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
-
- "sprintf-js": ["[email protected]", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
-
- "stackback": ["[email protected]", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
-
- "stat-mode": ["[email protected]", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
-
- "std-env": ["[email protected]", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
-
- "string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
-
- "strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
-
- "strip-literal": ["[email protected]", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
-
- "sumchecker": ["[email protected]", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
-
- "supports-color": ["[email protected]", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
-
- "svelte": ["[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-4D6lyrMHmDaZalQOEBMCWCCidyZjSnec14/oPn0k627G6goxcck9xqMwz1tFLlQz+ZFvtTTHfFOlUayuAz0z6Q=="],
-
- "svelte-check": ["[email protected]", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
-
- "tailwindcss": ["[email protected]", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
-
- "tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
-
- "tar": ["[email protected]", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
-
- "temp": ["[email protected]", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
-
- "temp-file": ["[email protected]", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="],
-
- "tiny-async-pool": ["[email protected]", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
-
- "tinybench": ["[email protected]", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
-
- "tinyexec": ["[email protected]", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
-
- "tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
-
- "tinypool": ["[email protected]", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
-
- "tinyrainbow": ["[email protected]", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
-
- "tinyspy": ["[email protected]", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
-
- "tmp": ["[email protected]", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
-
- "tmp-promise": ["[email protected]", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
-
- "tree-sitter-bash": ["[email protected]", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-7hMytuYIMoXOq24yRulgIxthE9YmggZIOHCyPTTuJcu6EU54tYD+4G39cUb28kxC6jMf/AbPfWGLQtgPTdh3xw=="],
-
- "truncate-utf8-bytes": ["[email protected]", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
-
- "type-fest": ["[email protected]", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
-
- "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "undici": ["[email protected]", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="],
-
- "undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
-
- "universalify": ["[email protected]", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
-
- "uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
-
- "utf8-byte-length": ["[email protected]", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
-
- "verror": ["[email protected]", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
-
- "vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
-
- "vite-node": ["[email protected]", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
-
- "vitefu": ["[email protected]", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
-
- "vitest": ["[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
-
- "vscode-jsonrpc": ["[email protected]", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
-
- "vscode-languageserver-types": ["[email protected]", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
-
- "web-tree-sitter": ["[email protected]", "", {}, "sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A=="],
-
- "which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
-
- "why-is-node-running": ["[email protected]", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
-
- "wrap-ansi": ["[email protected]", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
-
- "wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
-
- "xmlbuilder": ["[email protected]", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
-
- "y18n": ["[email protected]", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
-
- "yallist": ["[email protected]", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
-
- "yargs": ["[email protected]", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
-
- "yargs-parser": ["[email protected]", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
-
- "yauzl": ["[email protected]", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
-
- "yocto-queue": ["[email protected]", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
-
- "zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
-
- "zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
-
- "zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
-
- "@electron/asar/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
-
- "@electron/fuses/fs-extra": ["[email protected]", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
-
- "@electron/get/fs-extra": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
-
- "@electron/notarize/fs-extra": ["[email protected]", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
-
- "@electron/osx-sign/isbinaryfile": ["[email protected]", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
-
- "@electron/universal/fs-extra": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
-
- "@electron/universal/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
-
- "@electron/windows-sign/fs-extra": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
-
- "@malept/flatpak-bundler/fs-extra": ["[email protected]", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/[email protected]", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
-
- "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
-
- "@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
-
- "app-builder-lib/@electron/get": ["@electron/[email protected]", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
-
- "app-builder-lib/ci-info": ["[email protected]", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
-
- "app-builder-lib/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
- "clone-response/mimic-response": ["[email protected]", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
-
- "cross-spawn/which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
-
- "dir-compare/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
-
- "electron/@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
-
- "electron-winstaller/fs-extra": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
-
- "filelist/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
-
- "glob/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
-
- "global-agent/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
- "iconv-corefoundation/node-addon-api": ["[email protected]", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
-
- "lru-cache/yallist": ["[email protected]", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
-
- "node-abi/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
- "node-api-version/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
- "node-gyp/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
- "node-gyp/which": ["[email protected]", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
-
- "postject/commander": ["[email protected]", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
-
- "simple-update-notifier/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
-
- "svelte-check/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
-
- "tiny-async-pool/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
-
- "@electron/asar/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
-
- "@electron/get/fs-extra/jsonfile": ["[email protected]", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
-
- "@electron/get/fs-extra/universalify": ["[email protected]", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
-
- "@electron/universal/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
-
- "app-builder-lib/@electron/get/fs-extra": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
-
- "app-builder-lib/@electron/get/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
-
- "cross-spawn/which/isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
-
- "dir-compare/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
-
- "electron-winstaller/fs-extra/jsonfile": ["[email protected]", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
-
- "electron-winstaller/fs-extra/universalify": ["[email protected]", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
-
- "electron/@types/node/undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
-
- "filelist/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
-
- "glob/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
-
- "node-gyp/which/isexe": ["[email protected]", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
-
- "svelte-check/chokidar/readdirp": ["[email protected]", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
-
- "@electron/asar/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
- "@electron/universal/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
- "app-builder-lib/@electron/get/fs-extra/jsonfile": ["[email protected]", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
-
- "app-builder-lib/@electron/get/fs-extra/universalify": ["[email protected]", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
-
- "dir-compare/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
- "filelist/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
-
- "glob/minimatch/brace-expansion/balanced-match": ["[email protected]", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- }
-}
diff --git a/cc/01-tool-schema-analysis.md b/cc/01-tool-schema-analysis.md
deleted file mode 100644
index d803b2c..0000000
--- a/cc/01-tool-schema-analysis.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Tool Schema Analysis — Dispatch / AI SDK v6
-
-## How Dispatch Defines Tools
-
-Dispatch uses the **AI SDK v6** (`ai@^6.0.191`) with provider adapters (`@ai-sdk/anthropic@^3.0.79`, `@ai-sdk/openai-compatible@^2.0.48`).
-
-### The conversion pipeline
-
-```
-Zod schema (z.object({...}))
- → zodToJsonSchema() → JSON Schema Draft 7
- → jsonSchema() → AI SDK v6 inputSchema wrapper
- → tool() → AI SDK v6 Tool object (no execute fn)
- → streamText({tools}) → SDK → provider adapter → wire format
-```
-
-**Critical file:** `packages/core/src/tools/registry.ts` — the `toAISDKTool()` function.
-
-### What each tool's schema looks like after conversion
-
-For a typical tool like `read_file`:
-```json
-{
- "type": "object",
- "properties": {
- "path": { "type": "string", "description": "Path to the file..." },
- "offset": { "type": "integer", "minimum": 1, "description": "..." },
- "limit": { "type": "integer", "minimum": 1, "description": "..." }
- },
- "required": ["path"],
- "additionalProperties": false,
- "$schema": "http://json-schema.org/draft-07/schema#"
-}
-```
-
-### The Problem Surface
-
-The `zodToJsonSchema()` library (v3.x) generates full Draft 7 JSON Schema including:
-
-1. **`$schema` field** — `"http://json-schema.org/draft-07/schema#"` — Anthropic's API rejects this or silently ignores it depending on version
-2. **`additionalProperties: false`** — Anthropic may not handle this correctly; Claude's `tool_use` blocks sometimes include extra fields
-3. **`default` values** — Zod schemas with `.default()` produce JSON Schema `default` fields that Anthropic doesn't support
-4. **`minimum`/`maximum`** — Numeric constraints may cause issues if Claude passes values slightly outside bounds
-
-### What the oh-my-pi reference does differently
-
-The `references/oh-my-pi/packages/ai/src/utils/schema/normalize.ts` shows a `normalizeSchemaForCCA()` function that strips all of the above. Dispatch does **no normalization** before passing schemas to the AI SDK.
-
-### What the AI SDK's @ai-sdk/anthropic adapter does
-
-The adapter should convert the AI SDK's internal format to Anthropic's wire format. However, the adapter may:
-- Pass through JSON Schema fields that Anthropic doesn't support (like `$schema`, `additionalProperties`)
-- Not add parameter `description` fields from the schema to the Anthropic format
-- Not handle `default` values correctly
-
-### Files to inspect
-
-| File | What to check |
-|------|---------------|
-| `node_modules/@ai-sdk/anthropic/dist/index.mjs` | How tools are serialized for the wire |
-| `node_modules/ai/dist/index.mjs` | How `tool()` and `jsonSchema()` work |
-| `node_modules/zod-to-json-schema/dist/` | What `zodToJsonSchema` outputs for our schemas |
diff --git a/cc/02-anthropic-tool-format.md b/cc/02-anthropic-tool-format.md
deleted file mode 100644
index 3b2888c..0000000
--- a/cc/02-anthropic-tool-format.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# Anthropic Tool Format — What Claude Actually Expects
-
-## The Correct Anthropic API Tool Format
-
-```json
-{
- "tools": [
- {
- "name": "tool_name",
- "description": "What this tool does",
- "input_schema": {
- "type": "object",
- "properties": {
- "param1": {
- "type": "string",
- "description": "What param1 is"
- }
- },
- "required": ["param1"]
- }
- }
- ],
- "tool_choice": { "type": "auto" }
-}
-```
-
-### Key requirements
-
-| Field | Type | Required | Notes |
-|-------|------|----------|-------|
-| `name` | string | **Yes** | Must match `^[a-zA-Z0-9_-]{1,64}$` |
-| `description` | string | Strongly recommended | Claude uses this to decide when to call |
-| `input_schema` | object | **Yes** | JSON Schema; root must have `type: "object"` |
-| `input_schema.type` | string | **Yes** | MUST be `"object"` |
-| `input_schema.properties` | object | Recommended | Each property needs `type` and `description` |
-| `input_schema.required` | string[] | Optional | Lists required property names |
-
-### Anthropic does NOT support in input_schema
-
-1. **`$schema`** — JSON Schema meta-keyword; Anthropic rejects it
-2. **`additionalProperties`** — Not supported; can cause silent schema rejection
-3. **`default`** — Not part of Anthropic's JSON Schema subset
-4. **`type` as array** (e.g. `["string", "null"]`) — Rejected
-5. **`type: "null"`** — Rejected
-6. **`nullable`** keyword — Not supported
-7. **`anyOf` / `oneOf` / `allOf`** — Not supported (combiners)
-8. **`$ref` / `$defs` / `$dynamicRef`** — Not supported
-9. **`propertyNames`** — Not supported
-
-## What Claude Code (the CLI) Uses Internally
-
-Claude Code has its OWN internal tool definitions. They use a specific format that Claude has been trained on extensively. When you use Claude outside of Claude Code, the model loses:
-
-1. **The exact tool descriptions** it was trained on in Claude Code
-2. **The precise schema format** Claude Code uses
-3. **The system prompt** that tells Claude to use tools proactively
-4. **The billing/identity headers** that tell Anthropic this is a "real" CLI session
-
-### The billing header trick
-
-From `packages/core/src/credentials/claude.ts`:
-
-```
-x-anthropic-billing-header: cc_version=2.1.112.xxx; cc_entrypoint=sdk-cli; cch=xxxxx;
-```
-
-And the identity preamble:
-```
-"You are Claude Code, Anthropic's official CLI for Claude."
-```
-
-These are mirrored from opencode, but **they go beyond what the raw API needs**. They're hacks to get Anthropic's backend to treat the request as coming from Claude Code.
-
-## Why Claude Opus "Thinks Forever"
-
-Common causes when tools don't get called:
-
-1. **Tool schema has unsupported fields** → Anthropic silently ignores the tool → Claude never sees it → Claude just talks
-2. **Missing description on parameters** → Claude doesn't know what values to provide → stalls
-3. **`input_schema` missing `type: "object"`** → Anthropic rejects the tool entirely
-4. **`tool_choice` is wrong** → Set to `"none"` or Claude decides not to use tools
-5. **System prompt doesn't instruct tool use** → Claude doesn't realize it should call tools
-6. **Anthropic beta headers missing** → Extended thinking or new features might not work
-7. **Thinking budget too high** → Claude uses all tokens thinking and never gets to tool calls
-
-## Sources
-
-- https://docs.anthropic.com/en/docs/build-with-claude/tool-use
-- https://docs.aimlapi.com/capabilities/anthropic
-- https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic
diff --git a/cc/03-recommendations.md b/cc/03-recommendations.md
deleted file mode 100644
index 47ffaf8..0000000
--- a/cc/03-recommendations.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# Recommendations — Fixing Claude Opus Tool Calling
-
-## 1. Add Anthropic Schema Normalization
-
-**Problem:** `zodToJsonSchema()` generates Draft 7 JSON Schema with `$schema`, `additionalProperties`, and potentially `default` fields that Anthropic's API doesn't support.
-
-**Fix:** Add a normalization step between `zodToJsonSchema()` and `jsonSchema()` in `packages/core/src/tools/registry.ts` that strips unsupported fields:
-
-```typescript
-function normalizeForAnthropic(schema: Record<string, unknown>): Record<string, unknown> {
- // Remove fields Anthropic doesn't support
- delete schema.$schema;
- delete schema.additionalProperties;
- delete schema.default;
- // Strip from nested properties too
- if (schema.properties && typeof schema.properties === 'object') {
- for (const key of Object.keys(schema.properties as Record<string, unknown>)) {
- const prop = (schema.properties as Record<string, unknown>)[key] as Record<string, unknown>;
- delete prop.$schema;
- delete prop.additionalProperties;
- delete prop.default;
- }
- }
- return schema;
-}
-```
-
-**File to modify:** `packages/core/src/tools/registry.ts` — the `toAISDKTool()` function.
-
-## 2. Verify @ai-sdk/anthropic Adapter Version
-
-**Check:** `node_modules/@ai-sdk/anthropic/dist/index.mjs` — verify the adapter properly converts `input_schema` to Anthropic's `input_schema` format on the wire.
-
-The AI SDK v6 adapter should handle this, but verify by looking at how it serializes tools. The key serialization happens in the adapter's `convertToolsToAnthropic()` or similar function.
-
-## 3. Add `tool_choice: "any"` for Opus
-
-**Problem:** Opus may decide not to call tools even when it should.
-
-**Fix:** For Claude Opus sessions, consider setting `tool_choice: { type: "any" }` (or `"auto"`) to encourage tool use. Currently dispatch doesn't set any explicit `tool_choice` — the AI SDK default may be suboptimal.
-
-In `packages/core/src/agent/agent.ts`, the `streamText` options don't include `toolChoice`. Consider adding it conditionally for anthropic provider:
-
-```typescript
-const streamOptions = {
- model,
- messages: coreMessages,
- tools,
- ...(isClaudeOAuth ? { toolChoice: "auto" } : {}),
-};
-```
-
-## 4. Check Parameter Descriptions
-
-**Problem:** All tools have `.describe()` on their parameters, but verify the AI SDK's Anthropic adapter is forwarding these descriptions to the Anthropic `input_schema.properties.*.description` field.
-
-## 5. System Prompt Tool Instructions
-
-The current system prompt in `agent-manager.ts` lists tools generically:
-```
-"You have access to the following tools:\n\n{tool_list}\n\nWhen asked to work with files, use these tools."
-```
-
-For Claude Opus, add more explicit instructions about WHEN to use each tool and that it SHOULD use tools rather than just talking about solutions.
-
-## 6. Debugging: Log the Actual API Request
-
-Add logging to see what's actually sent to Anthropic for the `tools` parameter. The most reliable way is to add a `fetch` wrapper or check the `@ai-sdk/anthropic` adapter's serialization.
-
-Quick check:
-```bash
-node -e "
-import { zodToJsonSchema } from 'zod-to-json-schema';
-import { z } from 'zod';
-const schema = z.object({
- path: z.string().describe('Path to the file'),
- offset: z.number().int().optional(),
-});
-console.log(JSON.stringify(zodToJsonSchema(schema), null, 2));
-"
-```
-
-This will show exactly what JSON Schema is produced and whether it has `$schema`, `additionalProperties`, etc.
-
-## 7. Test with Raw Anthropic API
-
-Bypass the AI SDK entirely and test with a direct API call to isolate whether the issue is in the AI SDK adapter:
-
-```bash
-curl -X POST https://api.anthropic.com/v1/messages \
- -H "anthropic-version: 2023-06-01" \
- -H "x-api-key: $ANTHROPIC_API_KEY" \
- -H "content-type: application/json" \
- -d '{
- "model": "claude-opus-4-20250514",
- "max_tokens": 1024,
- "tools": [
- {
- "name": "read_file",
- "description": "Read the contents of a file",
- "input_schema": {
- "type": "object",
- "properties": {
- "path": {"type": "string", "description": "Path to file"}
- },
- "required": ["path"]
- }
- }
- ],
- "messages": [
- {"role": "user", "content": "Read /etc/hostname"}
- ]
- }'
-```
-
-If this works but dispatch doesn't, the issue is in the AI SDK adapter or the schema conversion.
diff --git a/cc/04-schema-debug.md b/cc/04-schema-debug.md
deleted file mode 100644
index 5889862..0000000
--- a/cc/04-schema-debug.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Schema Debug — What zodToJsonSchema Actually Produces
-
-Run this to see what the AI SDK actually sends to Anthropic:
-
-```bash
-node --experimental-strip-types -e "
-import { z } from 'zod';
-import { zodToJsonSchema } from 'zod-to-json-schema';
-
-const schema = z.object({
- path: z.string().describe('Path to the file, relative to the working directory'),
- offset: z.number().int().min(1).optional().describe('1-indexed start line. Default: 1.'),
- limit: z.number().int().min(1).optional().describe('Max lines to return. Default: 500. Hard cap: 5000.'),
-});
-
-console.log(JSON.stringify(zodToJsonSchema(schema), null, 2));
-"
-```
-
-## Check the @ai-sdk/anthropic adapter's tool serialization
-
-```bash
-grep -n 'input_schema\|tools\|jsonSchema\|convertTools' node_modules/@ai-sdk/anthropic/dist/index.mjs | head -30
-```
-
-## Check if streamText is receiving the tools correctly
-
-Look at how streamText processes tool options in the AI SDK:
-
-```bash
-grep -n 'tools\|toolChoice\|toolCall' node_modules/ai/dist/index.mjs | head -40
-```
-
-## Key questions to answer
-
-1. Does `zodToJsonSchema` output `$schema`? If yes, Anthropic may silently reject the tool definition.
-2. Does it output `additionalProperties`? Same concern.
-3. Does the `@ai-sdk/anthropic` adapter strip these before sending to the API?
-4. Does the adapter forward parameter `description` fields from JSON Schema to Anthropic's wire format?
-5. Is `tool_choice` being set or defaulting to something suboptimal?
diff --git a/cc/README.md b/cc/README.md
deleted file mode 100644
index a9c813d..0000000
--- a/cc/README.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Claude Opus Tool Calling Investigation
-
-## Problem
-Claude Opus "thinks forever" and doesn't call tools when used outside Claude Code's harness (in Dispatch's own agent harness).
-
-## Summary of Findings
-
-### 1. Tool Schema Format
-Dispatch uses the AI SDK v6 (`@ai-sdk/anthropic@^3.x`) which should handle format conversion automatically. However, `zodToJsonSchema()` produces Draft 7 JSON Schema with fields (`$schema`, `additionalProperties`, `default`) that Anthropic's API doesn't support. Dispatch does **no schema normalization**.
-
-### 2. Anthropic's requirements
-Anthropic's `input_schema` must be clean JSON Schema:
-- Root `type: "object"` is mandatory
-- No `$schema`, `additionalProperties`, `default`, `nullable`
-- No combiners (`anyOf`, `oneOf`, etc.)
-- Parameter `description` fields are strongly recommended
-
-### 3. Missing tool_choice
-Dispatch doesn't set `tool_choice` in `streamText()` options. The default may cause Opus to not call tools.
-
-### 4. System prompt
-The system prompt tells Opus what tools exist but may not be forceful enough about actually USING them instead of just talking about solutions.
-
-### 5. No Anthropic-specific schema normalization
-Unlike opencode's `normalizeSchemaForCCA()`, dispatch passes raw JSON Schema to the AI SDK without stripping unsupported fields.
-
-## Key Files
-
-| File | Purpose |
-|------|---------|
-| `packages/core/src/tools/registry.ts` | Tool → AI SDK conversion |
-| `packages/core/src/agent/agent.ts` | Agent loop, `streamText({tools})` |
-| `packages/api/src/agent-manager.ts` | Provides tool config to agent |
-| `packages/core/src/llm/provider.ts` | Provider creation |
-
-## Next Steps
-
-See `03-recommendations.md` for specific fixes to try.
diff --git a/dispatch.toml b/dispatch.toml
deleted file mode 100644
index 6964993..0000000
--- a/dispatch.toml
+++ /dev/null
@@ -1,89 +0,0 @@
-# Dispatch — Key & Permission Configuration
-# Credentials and API keys are stored in the SQLite database.
-# Use the Model Status panel to import credentials, or run bin/import-credentials.ts.
-#
-# ─── Global defaults ─────────────────────────────────────────────
-# A HOME-directory config at ~/.config/dispatch/dispatch.toml (override with
-# the DISPATCH_GLOBAL_CONFIG env var) is loaded and MERGED underneath this
-# project config, so machine-wide settings (e.g. globally available LSP
-# servers) work in any repository without per-repo setup. This LOCAL file
-# always takes PRECEDENCE on conflicts:
-# [lsp] merged by server id — a local [lsp.<id>] overrides the
-# global one of the same id; non-conflicting ids from both
-# stay active.
-# [[keys]] merged by id — a local key overrides the global key of the
-# same id; non-conflicting ids from both stay active.
-# [permissions] merged per group: nested { pattern = action } groups merge
-# pattern-by-pattern (local patterns win), and a local value
-# replaces a global one of the same key. Global patterns are
-# emitted first so local rules win at evaluation time.
-# Both files hot-reload: editing either re-merges and applies live.
-
-# ─── API Keys ───────────────────────────────────────────────────
-
-[[keys]]
-id = "claude-pro"
-provider = "anthropic"
-base_url = "https://api.anthropic.com/v1"
-credentials_file = "/home/tradam/.claude/.credentials-pro.json"
-
-[[keys]]
-id = "claude-max"
-provider = "anthropic"
-base_url = "https://api.anthropic.com/v1"
-credentials_file = "/home/tradam/.claude/.credentials-max.json"
-
-[[keys]]
-id = "opencode-1"
-provider = "opencode-go"
-base_url = "https://opencode.ai/zen/go/v1"
-
-[[keys]]
-id = "opencode-2"
-provider = "opencode-go"
-base_url = "https://opencode.ai/zen/go/v1"
-
-# ─── Permissions ─────────────────────────────────────────────────
-
-[permissions]
-read = "allow"
-
-# NOTE on ordering: rules are flattened in file order and the LAST matching
-# rule wins (see evaluate()'s findLast). So a broad fallback like "*" must be
-# placed FIRST and the more-specific overrides AFTER it — otherwise a trailing
-# "*" would shadow every specific rule above it. (Global+local merge preserves
-# this: global patterns are emitted before local ones so local overrides win.)
-
-[permissions.edit]
-"*" = "ask"
-"src/**" = "allow"
-
-[permissions.bash]
-"*" = "ask"
-"npm test" = "allow"
-"npm run *" = "allow"
-"git status" = "allow"
-"git diff" = "allow"
-"git log *" = "allow"
-"git branch *" = "allow"
-"git add *" = "allow"
-"git commit *" = "allow"
-"git push *" = "allow"
-"ls *" = "allow"
-
-[permissions.external_directory]
-"~/*" = "ask"
-"/tmp/*" = "allow"
-
-# ─── Language Servers (LSP) ──────────────────────────────────────
-# After `write_file` edits a matching file, dispatch appends any diagnostics.
-# Grant `perm_lsp` to also expose the on-demand `lsp` tool for hover/definition/references.
-
-[lsp.biome]
-command = ["/home/tradam/projects/dispatch/dispatch-source/node_modules/.bin/biome", "lsp-proxy"]
-extensions = [".ts", ".tsx", ".js", ".jsx", ".svelte", ".css", ".json"]
-
-[lsp.typescript-language-server]
-command = ["/home/tradam/.local/bin/typescript-language-server", "--stdio"]
-extensions = [".ts", ".tsx"]
-env = { PATH = "/home/tradam/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/bin" }
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
deleted file mode 100644
index 35234b8..0000000
--- a/docker-compose.prod.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-services:
- api:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - "3000:3000"
- environment:
- OPENCODE_API_KEY: ${OPENCODE_API_KEY}
- DISPATCH_MODEL: ${DISPATCH_MODEL:-deepseek-v4-flash}
- DISPATCH_WORKING_DIR: /app/workspace
- volumes:
- - workspace:/app/workspace
- restart: unless-stopped
-
-volumes:
- workspace:
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 4dd914d..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-services:
- api:
- build:
- context: .
- dockerfile: Dockerfile.dev
- command: ["bun", "--watch", "packages/api/src/index.ts"]
- network_mode: host
- volumes:
- - .:/app
- - ${HOME}/.claude:/home/${HOST_USER:-dispatch}/.claude
- - ${HOME}/.local/share/dispatch:/home/${HOST_USER:-dispatch}/.local/share/dispatch
- - ${HOME}/.skills:/home/${HOST_USER:-dispatch}/.skills
- - ${HOME}/.config/dispatch:/home/${HOST_USER:-dispatch}/.config/dispatch
- # Surface LLM debug logs on the host so they're inspectable without
- # `docker exec`. The container path is fixed to the same default the
- # debug-logger uses (DISPATCH_DEBUG_LLM_DIR), so on the host you can
- # `tail -F /tmp/dispatch/llm-debug/*.json` while running.
- - /tmp/dispatch/llm-debug:/tmp/dispatch/llm-debug
- env_file:
- - .env.dispatch
- environment:
- HOST_UID: ${HOST_UID:-1000}
- HOST_GID: ${HOST_GID:-1000}
- HOST_USER: ${HOST_USER:-dispatch}
- DISPATCH_WORKING_DIR: /app
- # Debug logger — forwarded from host via bin/up. When unset, the logger
- # is a no-op (early-return on the !ENABLED guard in debug-logger.ts).
- DISPATCH_DEBUG_LLM: ${DISPATCH_DEBUG_LLM:-}
- DISPATCH_DEBUG_LLM_VERBOSITY: ${DISPATCH_DEBUG_LLM_VERBOSITY:-}
- DISPATCH_DEBUG_USAGE: ${DISPATCH_DEBUG_USAGE:-}
- # Where the debug logger writes its JSON files INSIDE the container.
- # `DEBUG=1 bin/up` sets this to /app/logging, which maps to the
- # gitignored `logging/` dir on the host via the `.:/app` bind mount.
- # Empty ⇒ the logger falls back to /tmp/dispatch/llm-debug.
- DISPATCH_DEBUG_LLM_DIR: ${DISPATCH_DEBUG_LLM_DIR:-}
-
- frontend:
- build:
- context: .
- dockerfile: Dockerfile.dev
- command: ["bun", "run", "--cwd", "packages/frontend", "dev", "--", "--host"]
- network_mode: host
- volumes:
- - .:/app
- depends_on:
- api:
- condition: service_started
- environment:
- HOST_UID: ${HOST_UID:-1000}
- HOST_GID: ${HOST_GID:-1000}
- HOST_USER: ${HOST_USER:-dispatch}
- SKIP_INSTALL: "1"
diff --git a/docker/cs/fuzzy-distance.patch b/docker/cs/fuzzy-distance.patch
deleted file mode 100644
index e432986..0000000
--- a/docker/cs/fuzzy-distance.patch
+++ /dev/null
@@ -1,159 +0,0 @@
-Fix cs fuzzy matching to honour true Levenshtein edit distance (insertions and
-deletions), not just same-length substitutions.
-
-Upstream cs (v3.1.0) implements `term~N` fuzzy search by scanning only
-same-length windows of the content: for a term of length L it compares every
-L-character substring and keeps those within N edits. Because every candidate
-window is exactly L long, the only edits it can ever observe are substitutions.
-A mid-word insertion or deletion — e.g. `computSlipAngle~1` for the real symbol
-`computeSlipAngle` (a dropped 'e'), or `houss~1` vs `house` — changes the length
-by one and so is never matched, even though it is edit distance 1. This
-contradicts cs's own documentation ("fuzzy match within 1 or 2 distance").
-
-The fix scans windows of every plausible length in
-[termLen-maxDist, termLen+maxDist] (clamped at 1) at each offset and keeps the
-best (lowest-distance) match, so insertions and deletions match too. fuzzyFind
-records the best window per start and advances past it to avoid emitting a swarm
-of overlapping locations for one logical match.
-
-Purely localised to the two fuzzy helpers in pkg/search/executor.go (plus a
-test update: the pre-existing `hovze~2` case asserted the old substitution-only
-behaviour, where the distance-2 term coincidentally failed to match a shorter
-window that is in fact within distance 2; it is replaced with an unambiguous
-distance-1 case and new mid-word insertion/deletion cases). Passes cs's own
-pkg/search + pkg/ranker test suites. Applied during the Docker build and the
-native package build via `git apply` against the pinned v3.1.0 checkout (see
-Dockerfile / Dockerfile.dev / packaging/PKGBUILD). Candidate for upstreaming to
-boyter/cs (the defect is unreported there).
-
-diff --git a/pkg/search/executor.go b/pkg/search/executor.go
-index 175d458..1c422d2 100644
---- a/pkg/search/executor.go
-+++ b/pkg/search/executor.go
-@@ -632,17 +632,67 @@ func min3(a, b, c int) int {
- return c
- }
-
--// fuzzyContains checks if any same-length substring of content matches
--// the term within the given edit distance (substitution-based matching).
-+// fuzzyWindowBounds returns the inclusive range of substring lengths that
-+// could be within maxDist edits of a term of length termLen. A Levenshtein
-+// distance of d can only be achieved between strings whose lengths differ by
-+// at most d (each insertion or deletion changes the length by one), so any
-+// candidate window must have a length in [termLen-maxDist, termLen+maxDist].
-+// The lower bound is clamped to 1 so we never form a zero-length window.
-+func fuzzyWindowBounds(termLen, maxDist int) (int, int) {
-+ minLen := termLen - maxDist
-+ if minLen < 1 {
-+ minLen = 1
-+ }
-+ return minLen, termLen + maxDist
-+}
-+
-+// bestFuzzyMatchAt returns the length of the best (lowest-distance) substring
-+// of content starting at offset i that is within maxDist edits of term, or -1
-+// if none is. Windows of every plausible length are tried (see
-+// fuzzyWindowBounds) so insertions and deletions match, not just
-+// substitutions — e.g. "computSlipAngle" (a dropped 'e') matches
-+// "computeSlipAngle" at distance 1. Ties prefer the length closest to termLen.
-+func bestFuzzyMatchAt(content, term string, i, maxDist, minLen, maxLen int) int {
-+ contentLen := len(content)
-+ bestLen := -1
-+ bestDist := maxDist + 1
-+ bestDelta := 0
-+ for wl := minLen; wl <= maxLen; wl++ {
-+ if i+wl > contentLen {
-+ break
-+ }
-+ d := levenshtein(content[i:i+wl], term)
-+ if d > maxDist {
-+ continue
-+ }
-+ delta := wl - len(term)
-+ if delta < 0 {
-+ delta = -delta
-+ }
-+ if d < bestDist || (d == bestDist && delta < bestDelta) {
-+ bestDist = d
-+ bestLen = wl
-+ bestDelta = delta
-+ }
-+ }
-+ return bestLen
-+}
-+
-+// fuzzyContains reports whether any substring of content is within maxDist
-+// edits (true Levenshtein: insertions, deletions, and substitutions) of term.
- func fuzzyContains(content, term string, maxDist, termLen int) bool {
- contentLen := len(content)
-- if contentLen == 0 || termLen == 0 || termLen > contentLen {
-+ if contentLen == 0 || termLen == 0 {
-+ return false
-+ }
-+
-+ minLen, maxLen := fuzzyWindowBounds(termLen, maxDist)
-+ if minLen > contentLen {
- return false
- }
-
-- for i := 0; i <= contentLen-termLen; i++ {
-- window := content[i : i+termLen]
-- if levenshtein(window, term) <= maxDist {
-+ for i := 0; i <= contentLen-minLen; i++ {
-+ if bestFuzzyMatchAt(content, term, i, maxDist, minLen, maxLen) >= 0 {
- return true
- }
- }
-@@ -651,18 +701,30 @@ func fuzzyContains(content, term string, maxDist, termLen int) bool {
-
- // fuzzyFind finds all match locations in content that are within the given
- // edit distance of the term. Returns [][]int where each entry is [start, end].
-+// Like fuzzyContains it considers variable-length windows so insertions and
-+// deletions match. To avoid emitting a swarm of overlapping windows for one
-+// logical match, it records the best window at each start and then advances
-+// past it.
- func fuzzyFind(content, term string, maxDist, termLen int) [][]int {
- contentLen := len(content)
-- if contentLen == 0 || termLen == 0 || termLen > contentLen {
-+ if contentLen == 0 || termLen == 0 {
-+ return nil
-+ }
-+
-+ minLen, maxLen := fuzzyWindowBounds(termLen, maxDist)
-+ if minLen > contentLen {
- return nil
- }
-
- var locs [][]int
-- for i := 0; i <= contentLen-termLen; i++ {
-- window := content[i : i+termLen]
-- if levenshtein(window, term) <= maxDist {
-- locs = append(locs, []int{i, i + termLen})
-- }
-+ for i := 0; i <= contentLen-minLen; {
-+ wl := bestFuzzyMatchAt(content, term, i, maxDist, minLen, maxLen)
-+ if wl < 0 {
-+ i++
-+ continue
-+ }
-+ locs = append(locs, []int{i, i + wl})
-+ i += wl
- }
- return locs
- }
-diff --git a/pkg/search/search_test.go b/pkg/search/search_test.go
-index 6cbd01f..eacbd75 100644
---- a/pkg/search/search_test.go
-+++ b/pkg/search/search_test.go
-@@ -57,7 +57,10 @@ func TestExecutor(t *testing.T) {
- {"Fuzzy Distance 1", "houss~1", false, []string{"src/main/file1.go"}}, // "houss" is distance 1 from "house" (only in file1)
- {"Fuzzy Distance 1 No Match", "zzz~1", false, []string{}},
- {"Fuzzy AND Keyword", "houss~1 AND brown", false, []string{"src/main/file1.go"}},
-- {"Fuzzy Distance 2", "hovze~2", false, []string{"src/main/file1.go"}}, // "hovze" is distance 2 from "house" (u→v, s→z), only in file1
-+ {"Fuzzy Distance 2", "houze~2", false, []string{"src/main/file1.go"}}, // "houze" is distance 1 from "house" (s→z), only in file1
-+ // Mid-word insertion/deletion must match (true Levenshtein, not just same-length substitution).
-+ {"Fuzzy Distance 1 deletion", "hose~1", false, []string{"src/main/file1.go"}}, // "hose" -> "house" (insert 'u'), distance 1
-+ {"Fuzzy Distance 1 insertion", "houxse~1", false, []string{"src/main/file1.go"}}, // "houxse" -> "house" (delete 'x'), distance 1
-
- // Colon filter syntax
- {"Colon file filter", "cat file:file1", false, []string{"src/main/file1.go"}},
diff --git a/docker/cs/luau-declarations.patch b/docker/cs/luau-declarations.patch
deleted file mode 100644
index 794ecac..0000000
--- a/docker/cs/luau-declarations.patch
+++ /dev/null
@@ -1,32 +0,0 @@
-Add a Luau declaration-pattern table to the cs structural ranker.
-
-Upstream cs (v3.1.0) ships a "Lua" entry in languageDeclarationPatterns but no
-"Luau" entry, even though its bundled scc database recognises ".luau" files as a
-distinct "Luau" language. Without a matching declaration table, every match in a
-.luau file is classified as a plain "usage": --only-declarations returns nothing
-and the structural ranker gives definitions no boost. This is the dominant file
-type in Roblox codebases, so we add a Luau entry that mirrors Lua's function
-prefixes and additionally covers Luau's `type` / `export type` declarations.
-
-This is a purely additive change (one new map entry); it does not alter any
-existing language's behaviour and passes cs's own pkg/ranker test suite. Applied
-during the Docker build via `git apply` against the pinned v3.1.0 checkout
-(see Dockerfile / Dockerfile.dev). Candidate for upstreaming to boyter/cs.
-
-diff --git a/pkg/ranker/declarations.go b/pkg/ranker/declarations.go
-index 42cd934..36f9f68 100644
---- a/pkg/ranker/declarations.go
-+++ b/pkg/ranker/declarations.go
-@@ -187,6 +187,12 @@ var languageDeclarationPatterns = map[string][]DeclarationPattern{
- {Prefix: []byte("function ")},
- {Prefix: []byte("local function ")},
- },
-+ "Luau": {
-+ {Prefix: []byte("function ")},
-+ {Prefix: []byte("local function ")},
-+ {Prefix: []byte("type ")},
-+ {Prefix: []byte("export type ")},
-+ },
- "Scala": {
- {Prefix: []byte("def ")},
- {Prefix: []byte("val ")},
diff --git a/docker/entrypoint.dev.sh b/docker/entrypoint.dev.sh
deleted file mode 100755
index 0feb37a..0000000
--- a/docker/entrypoint.dev.sh
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-# ─── Match host user inside container ────────────────────────────
-# Ensures the process runs as the host UID/GID with a matching
-# home directory so volume mounts and config paths are consistent.
-
-HOST_UID="${HOST_UID:-1000}"
-HOST_GID="${HOST_GID:-1000}"
-HOST_USER="${HOST_USER:-dispatch}"
-
-USER_HOME="/home/$HOST_USER"
-
-# Create group if it doesn't exist
-if ! getent group "$HOST_GID" > /dev/null 2>&1; then
- groupadd -g "$HOST_GID" "$HOST_USER"
-fi
-
-# Ensure user with this UID has the correct home directory
-if id -u "$HOST_UID" > /dev/null 2>&1; then
- USER_NAME=$(getent passwd "$HOST_UID" | cut -d: -f1)
- usermod -d "$USER_HOME" "$USER_NAME" 2>/dev/null || true
-else
- useradd -u "$HOST_UID" -g "$HOST_GID" -d "$USER_HOME" -m -s /bin/bash "$HOST_USER"
- USER_NAME="$HOST_USER"
-fi
-
-# Ensure home and data directories exist with correct ownership
-mkdir -p "$USER_HOME" "$USER_HOME/.local/share/dispatch"
-chown "$HOST_UID:$HOST_GID" "$USER_HOME"
-chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.local/share/dispatch"
-
-# Ensure .claude is accessible
-if [ -d "$USER_HOME/.claude" ]; then
- chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.claude" 2>/dev/null || true
-fi
-
-# Ensure all node_modules are writable (created as root during build)
-find /app -name node_modules -type d -maxdepth 3 -exec chown -R "$HOST_UID:$HOST_GID" {} + 2>/dev/null || true
-
-# Install/update dependencies as the target user (skip with SKIP_INSTALL=1)
-if [ "${SKIP_INSTALL:-}" != "1" ]; then
- su -s /bin/bash - "$USER_NAME" -c "export HOME=$USER_HOME && cd /app && bun install"
-fi
-
-# ─── Env vars that must survive the `su -` login-shell barrier ──
-# `su -` resets the environment to a clean login profile (TERM, PATH,
-# HOME, SHELL, USER, LOGNAME, MAIL only — everything else is wiped).
-# Anything compose/Dockerfile set on PID 1 that the actual app process
-# needs has to be re-exported explicitly here. The
-# `${VAR-}` form (note: NOT `${VAR:-}`) preserves the empty-string case
-# so a deliberately-blank var stays blank instead of going undefined.
-FORWARD_VARS=(
- DISPATCH_DEBUG_LLM
- DISPATCH_DEBUG_LLM_VERBOSITY
- DISPATCH_DEBUG_LLM_DIR
- DISPATCH_DEBUG_USAGE
- DISPATCH_WORKING_DIR
- PORT
-)
-EXPORTS=""
-for var in "${FORWARD_VARS[@]}"; do
- # Use indirect expansion to read the var's current value, default to empty.
- val="${!var-}"
- # Single-quote-escape the value so shell-meaningful chars survive.
- esc=${val//\'/\'\\\'\'}
- EXPORTS+="export $var='$esc'; "
-done
-
-# Execute the main command as the target user
-exec su -s /bin/bash - "$USER_NAME" -c "export HOME=$USER_HOME && $EXPORTS cd /app && exec $*"
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
deleted file mode 100644
index 4f35d94..0000000
--- a/docker/entrypoint.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-# Production entrypoint
-# Future phases: add database migrations here
-
-# Execute the main command
-exec "$@"
diff --git a/package.json b/package.json
deleted file mode 100644
index 6c15f29..0000000
--- a/package.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "dispatch",
- "private": true,
- "workspaces": [
- "packages/*"
- ],
- "scripts": {
- "dev:api": "bun --watch packages/api/src/index.ts",
- "dev:frontend": "bun run --cwd packages/frontend dev",
- "check": "biome check .",
- "check:fix": "biome check --write .",
- "test": "vitest run",
- "test:watch": "vitest"
- },
- "devDependencies": {
- "@biomejs/biome": "^2.4.15",
- "typescript": "^5.7.0",
- "vitest": "^3.0.0"
- }
-}
diff --git a/packages/api/package.json b/packages/api/package.json
deleted file mode 100644
index deea9f6..0000000
--- a/packages/api/package.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "@dispatch/api",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "src/index.ts",
- "scripts": {
- "dev": "bun --watch src/index.ts",
- "test": "vitest run",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit"
- },
- "dependencies": {
- "hono": "^4.0.0",
- "@dispatch/core": "workspace:*"
- },
- "devDependencies": {
- "@types/bun": "latest"
- }
-}
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
deleted file mode 100644
index 539663c..0000000
--- a/packages/api/src/agent-manager.ts
+++ /dev/null
@@ -1,2453 +0,0 @@
-import {
- Agent,
- type AgentEvent,
- type AgentModelEntry,
- type AgentSkillMapping,
- type AgentStatus,
- appendChunks,
- appendEventToChunks,
- BackgroundShellStore,
- BackgroundTranscriptStore,
- buildCompactionRequest,
- buildSummaryTurnText,
- type ChatMessage,
- type Chunk,
- type ClaudeAccount,
- clearSpillForTab,
- configToRuleset,
- createConfigWatcher,
- createKeyUsageTool,
- createListFilesTool,
- createLspTool,
- createReadFileSliceTool,
- createReadFileTool,
- createReadTabTool,
- createRetrieveTool,
- createRunShellTool,
- createSearchCodeTool,
- createSendToTabTool,
- createSkillsWatcher,
- createSummonTool,
- createTab,
- createTaskListTool,
- createWebSearchTool,
- createWriteFileTool,
- createYoutubeTranscribeTool,
- type DispatchConfig,
- expandAgentToolNames,
- explodeTurn,
- explodeUserText,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getChunksForTab,
- getClaudeAccountsFromDB,
- getMessagesForTab,
- getSetting,
- getTab,
- getUsageStatsForTab,
- groupRowsToMessages,
- LspManager,
- listOpenTabs,
- loadAgent,
- loadAgents,
- loadConfig,
- loadSkills,
- ModelRegistry,
- type QueuedMessage,
- type ReasoningEffort,
- type ResolvedLspServer,
- refreshAccountCredentials,
- refreshAccountCredentialsAsync,
- rekeyChunks,
- reportDiagnostics,
- resolveApiKey,
- resolveServersFromConfig,
- resolveTabPrefix,
- type SkillDefinition,
- type SystemChunkKind,
- shortestUniquePrefix,
- type TabResolution,
- type TabStatusSnapshot,
- TaskList,
- toAvailableSubagents,
- toAvailableUserAgents,
- type UsageData,
- type UsageStats,
- type UserContentPart,
- validateConfig,
- watchDirConfig,
-} from "@dispatch/core";
-import type { PermissionManager } from "./permission-manager.js";
-import { setConfigGetter } from "./routes/config.js";
-import { setAccountsGetter, setModelsGetter } from "./routes/models.js";
-import { setSkillsGetter } from "./routes/skills.js";
-import { setTabsAgentManager } from "./routes/tabs.js";
-
-const TOOL_DESCRIPTIONS: Record<string, string> = {
- read_file: "Read the contents of a file",
- read_file_slice:
- "Read a character-range slice of a single line in a file (for inspecting long lines that read_file truncated)",
- list_files: "List files and directories",
- write_file: "Write content to a file (creates parent directories if needed)",
- run_shell:
- "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Set background=true to run in the background and get a job_id for later retrieval. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
- search_code:
- "Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.",
- todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.",
- key_usage:
- "Report current usage levels for configured API keys: provider, active/exhausted status, remaining rate-limit headroom and reset times per window (5-hour, weekly, monthly where available), and whether the figures are live or cached. Pass key_id for one key; omit to report all. Supported for anthropic and opencode-go keys.",
- summon:
- "Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.",
- retrieve:
- "Wait for a background task to finish and get its result (blocking). Pass the job_id or agent_id.",
- web_search: "Search the web and optionally scrape full page content from results.",
- youtube_transcribe:
- "Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.",
- send_to_tab:
- "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.",
- read_tab:
- "Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.",
- lsp: "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file: diagnostics, hover, definition, references, or documentSymbol. Line/character are 1-based.",
-};
-
-/**
- * Maximum number of CONSECUTIVE agent-to-agent auto-wakes a tab will accept
- * before it stops auto-responding and waits for a human. Each `send_to_tab`
- * that would wake an idle tab consumes one unit; any human-originated message
- * (e.g. via `POST /chat`) refills the budget to full. This bounds runaway
- * agent ping-pong loops (A wakes B wakes A ...) that would otherwise spend
- * tokens unbounded with no human in the loop. See notes/plan-tab-comm.md.
- */
-const MAX_AGENT_AUTO_WAKES = 6;
-
-/**
- * Cap on how many OTHER files' LSP error blocks are appended to a write_file
- * result, after the written file's own errors. Bounds context spend when a
- * single edit surfaces project-wide diagnostics. Mirrors opencode's
- * MAX_PROJECT_DIAGNOSTICS_FILES.
- */
-const MAX_LSP_OTHER_FILE_DIAGNOSTICS = 5;
-
-const DEFAULT_SYSTEM_PROMPT =
- "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise.";
-
-const TASK_MANAGEMENT_GUIDANCE = `
-## Task Management
-
-You have access to the \`todo\` tool to plan and track tasks. Use it VERY frequently so the user can see your plan and progress in real time. It is also a powerful planning aid: breaking larger work into smaller steps keeps you from forgetting important tasks — that is unacceptable.
-
-The \`todo\` tool is DECLARATIVE: every call sends the ENTIRE list in the \`todos\` parameter and replaces the previous list. There are no ids and no per-item actions — to change one item, resend the whole list with that item updated. To clear the list, send an empty array.
-
-### When to use
-- A task needs 3+ distinct steps, or benefits from planning
-- The user gives multiple tasks (numbered or comma-separated) or asks for a todo list
-- New instructions arrive — capture them as todos
-- You start a task — mark it in_progress (only one at a time) before working
-- You finish a task — mark it completed and add any follow-ups discovered
-
-### When NOT to use
-- A single, straightforward task (or fewer than 3 trivial steps)
-- Purely informational or conversational requests
-- When tracking adds no organizational value
-
-### States
-- pending — not started
-- in_progress — actively working (exactly ONE at a time)
-- completed — finished successfully
-- cancelled — no longer needed
-
-### Rules
-- Send the full desired list every time; the tool replaces the stored list
-- Update status in real time; do NOT batch completions
-- Mark completed only after the work is actually done (including any required verification), never on intent
-- Keep exactly one in_progress while work remains; if blocked, keep it in_progress and add a follow-up todo describing the blocker
-
-### Examples
-
-User: "Run the build and fix any type errors"
-Write the list, then work it: send [{content:"Run the build", status:"in_progress"}, {content:"Fix any type errors", status:"pending"}]. Run the build. If it surfaces 10 errors, resend the whole list — the build item completed, plus one item per error — then drive each to completed one at a time.
-
-User: "How do I print Hello World in Python?"
-No todo needed — this is a single informational question.
-
-User: "Rename getUser to fetchUser across the project"
-Send [{content:"Search for all occurrences of getUser", status:"in_progress"}, ...]. After the grep reveals the files, resend the whole list with one item per file, then work through them, resending the list as each flips to completed.
-`.trim();
-
-/**
- * Returns true for OpenCode Go models served via the Anthropic-format
- * `/messages` endpoint (MiniMax M2.x, Qwen3.x Plus). See
- * https://opencode.ai/docs/go/#endpoints for the per-model endpoint table.
- */
-function isOpencodeGoAnthropicModel(modelId: string): boolean {
- return modelId.startsWith("minimax-") || modelId.startsWith("qwen");
-}
-
-function buildSystemPrompt(toolNames: string[], basePrompt?: string): string {
- const base = basePrompt || DEFAULT_SYSTEM_PROMPT;
- const toolList = toolNames
- .filter((name) => TOOL_DESCRIPTIONS[name])
- .map((name) => `- ${name}: ${TOOL_DESCRIPTIONS[name]}`)
- .join("\n");
-
- if (!toolList) return base;
-
- const hasTodo = toolNames.includes("todo");
- const hasSummon = toolNames.includes("summon");
- let prompt = `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`;
- if (hasTodo) {
- prompt += `\n\n${TASK_MANAGEMENT_GUIDANCE}`;
- }
- if (hasSummon) {
- prompt +=
- '\n\nYou have pre-configured subagent types. Use summon(agent="slug", task="...") to delegate specialized work to a subagent. Use list_files and read_file to inspect available agent definitions.';
- }
- return prompt;
-}
-
-interface TabAgent {
- agent: Agent | null;
- status: AgentStatus;
- keyId: string | null;
- modelId: string | null;
- taskList: TaskList;
- _lastPermKey?: string;
- /** Ordered key+model fallback hierarchy from the agent definition. */
- agentModels?: AgentModelEntry[];
- /** Abort controller for cancelling a running agent. */
- abortController?: AbortController;
- /** For child agents: resolves when the agent finishes its task. */
- completionResolve?: (
- result: { status: "done"; result: string } | { status: "error"; error: string },
- ) => void;
- completionPromise?: Promise<
- { status: "done"; result: string } | { status: "error"; error: string }
- >;
- /** Accumulated final text output from the child agent. */
- finalOutput?: string;
- /** Tools whitelist for child agents (set by summon). */
- toolsOverride?: string[];
- /** Working directory override for child agents. */
- workingDirectoryOverride?: string;
- /** Queue of messages sent while the agent is running. */
- messageQueue: QueuedMessage[];
- /** Callbacks to wake up blocking tools waiting for queued messages. */
- queueListeners: Array<() => void>;
- /** Store for shell commands backgrounded due to user interrupt. */
- shellStore: BackgroundShellStore;
- /** Store for transcript requests backgrounded due to user interrupt. */
- transcriptStore: BackgroundTranscriptStore;
- /**
- * In-flight assistant chunks for the active turn. `null` when no turn is
- * running. Out-of-band system events (config-reload, cancel, etc.) push
- * onto this list when present; it is exploded into chunk rows when the
- * turn flushes.
- */
- currentChunks: Chunk[] | null;
- /**
- * Opaque id of the in-flight assistant turn, used as the `currentAssistantId`
- * in the WS status snapshot so a reconnecting frontend can align its local
- * streaming message. (No longer a DB row id — the turn is many chunk rows.)
- */
- currentAssistantId: string | null;
- /**
- * `turn_id` shared by the current turn's user message and assistant chunk
- * rows. Set at the start of `processMessage`, cleared when the turn ends.
- */
- currentTurnId: string | null;
- /**
- * Remaining consecutive agent-to-agent auto-wakes this tab will accept
- * before requiring human intervention (see `MAX_AGENT_AUTO_WAKES`).
- * Refilled to the max by any human-originated `deliverMessage`; decremented
- * each time an agent-originated `send_to_tab` wakes this tab from idle. When
- * it hits 0, further agent messages are queued but do NOT start a turn.
- */
- autoWakeBudget: number;
- /**
- * True while this tab is the SOURCE of an in-flight compaction. New
- * messages are queued (not started) until compaction settles so the
- * conversation can't mutate mid-summary.
- */
- compacting?: boolean;
-}
-
-export class AgentManager {
- private tabAgents: Map<string, TabAgent> = new Map();
- private messageCount = 0;
- private eventListeners: Set<(event: AgentEvent & { tabId: string }) => void> = new Set();
- private permissionManager: PermissionManager | undefined;
-
- private config: DispatchConfig;
- private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] };
- private modelRegistry: ModelRegistry | null = null;
-
- private configWatcher: { close(): void } | null = null;
- private skillsWatcher: { close(): void } | null = null;
-
- private claudeAccounts: ClaudeAccount[] = [];
-
- /**
- * Process-wide owner of LSP client lifecycles. Servers are declared in the
- * `dispatch.toml` of a tab's effective working directory; clients are
- * spawned lazily per (root + server) and reused across tabs/turns. Shut
- * down in `destroy()`.
- */
- private lspManager: LspManager = new LspManager();
- /**
- * Cache of resolved LSP servers per working directory, so we parse each
- * directory's `dispatch.toml` `[lsp]` block once. Cleared wholesale on any
- * config hot-reload (the watcher fires for the root config; directory-level
- * configs are re-read on demand after a clear).
- */
- private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map();
- /**
- * One file watcher per distinct SUBDIRECTORY config we've cached in
- * `lspServersByDir`. The main `configWatcher` only watches the root +
- * global `dispatch.toml`; a tab whose effective working directory is a
- * subdirectory with its own `dispatch.toml` needs its cache entry cleared
- * when THAT file changes. Keyed by directory; closed on full reload (the
- * cache is dropped wholesale then) and in `destroy()`.
- */
- private lspDirWatchers: Map<string, { close(): void }> = new Map();
- /** Root working directory watched by `configWatcher` (constructor). */
- private rootWorkingDirectory = "";
-
- constructor(permissionManager?: PermissionManager) {
- this.permissionManager = permissionManager;
-
- const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- this.rootWorkingDirectory = workingDirectory;
-
- // Load initial config
- this.config = loadConfig(workingDirectory);
- const { errors } = validateConfig(this.config);
- if (errors.length > 0) {
- for (const err of errors) {
- console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`);
- }
- }
-
- // Initialize model registry + resolver if config has models and keys
- this._initModelRegistry(this.config);
-
- // Load initial skills
- this.skillsData = loadSkills(workingDirectory);
-
- // Discover Claude accounts
- this._refreshClaudeAccounts();
-
- // Wire route getters
- setConfigGetter(() => this.config);
- setSkillsGetter(() => this.skillsData);
- setModelsGetter(() => this.modelRegistry);
- setAccountsGetter(() => this.claudeAccounts);
- setTabsAgentManager(() => this);
-
- // Set up hot-reload watchers
- this.configWatcher = createConfigWatcher(workingDirectory, (newConfig) => {
- this.config = newConfig;
- const { errors: newErrors } = validateConfig(newConfig);
- if (newErrors.length > 0) {
- for (const err of newErrors) {
- console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`);
- }
- }
- // Update model registry with new config
- this._initModelRegistry(newConfig);
- // LSP server config may have changed — drop the per-directory cache
- // so the next tool build re-reads each working directory's
- // `dispatch.toml` `[lsp]` block.
- this.lspServersByDir.clear();
- // Tear down the per-subdirectory LSP watchers too; they are lazily
- // re-registered by `getLspServersForDir` as directories are re-cached.
- for (const watcher of this.lspDirWatchers.values()) watcher.close();
- this.lspDirWatchers.clear();
- // Re-discover Claude accounts: a config reload may accompany freshly
- // imported credentials, and (critically) lets a process that failed
- // account discovery at boot recover without a full restart.
- this._refreshClaudeAccounts();
- // Invalidate cached agents so next message uses updated config
- for (const tabAgent of this.tabAgents.values()) {
- tabAgent.agent = null;
- }
- // Emit config-reload to all tabs (and persist as a system chunk)
- for (const tabId of this.tabAgents.keys()) {
- this.emit({ type: "config-reload" }, tabId);
- this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded");
- }
- });
-
- this.skillsWatcher = createSkillsWatcher(workingDirectory, (result) => {
- this.skillsData = result;
- // Invalidate cached agents so next message uses updated skills
- for (const tabAgent of this.tabAgents.values()) {
- tabAgent.agent = null;
- }
- // Emit config-reload to all tabs (and persist as a system chunk)
- for (const tabId of this.tabAgents.keys()) {
- this.emit({ type: "config-reload" }, tabId);
- this.routeSystemEventToTab(tabId, "config-reload", "Skills reloaded");
- }
- });
- }
-
- private _refreshClaudeAccounts(): void {
- try {
- this.claudeAccounts = getClaudeAccountsFromDB();
- if (this.claudeAccounts.length > 0) {
- console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`);
- }
- } catch (err) {
- console.warn(
- `dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
-
- /**
- * Resolve (and cache) the LSP servers configured for a working directory.
- *
- * LSP config is resolved by `loadConfig`, which merges the HOME-directory
- * global `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the
- * tab's effective working-directory `dispatch.toml` — local `[lsp.<id>]`
- * entries override global ones sharing the same id, while global-only
- * servers stay active in every repository. We read+merge that config once
- * per directory and cache the resolved servers; the cache is cleared on
- * config hot-reload. Returns `[]` when neither config declares an `[lsp]`
- * block (the common case).
- */
- private getLspServersForDir(dir: string): ResolvedLspServer[] {
- const cached = this.lspServersByDir.get(dir);
- if (cached) return cached;
- let servers: ResolvedLspServer[] = [];
- try {
- const dirConfig = loadConfig(dir);
- servers = resolveServersFromConfig(dirConfig.lsp);
- } catch (err) {
- console.warn(
- `dispatch: failed to load LSP config for ${dir}: ${err instanceof Error ? err.message : String(err)}`,
- );
- servers = [];
- }
- this.lspServersByDir.set(dir, servers);
- // Hot-reload for SUBDIRECTORY configs: the root/global watcher in the
- // constructor does not cover a nested `dispatch.toml`. Register a
- // one-per-dir watcher the first time we cache a directory so editing
- // its config invalidates just this entry (and cached agents) without a
- // restart. The root working directory is already covered by
- // `configWatcher`, so skip it to avoid a redundant watch.
- this.ensureLspDirWatcher(dir);
- return servers;
- }
-
- /**
- * Register (once) a file watcher on `<dir>/dispatch.toml` so a change to a
- * subdirectory config invalidates that directory's LSP cache entry and
- * any cached agents. No-op for the root working directory (already watched
- * by `configWatcher`) and for directories already being watched.
- */
- private ensureLspDirWatcher(dir: string): void {
- if (dir === this.rootWorkingDirectory) return;
- if (this.lspDirWatchers.has(dir)) return;
- const watcher = watchDirConfig(dir, () => {
- // Drop just this directory's resolved servers; the next tool build
- // re-reads (and re-merges global) for it.
- this.lspServersByDir.delete(dir);
- // Invalidate cached agents so the next message rebuilds tools with
- // the updated server set.
- for (const tabAgent of this.tabAgents.values()) {
- tabAgent.agent = null;
- }
- for (const tabId of this.tabAgents.keys()) {
- this.emit({ type: "config-reload" }, tabId);
- this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded");
- }
- });
- this.lspDirWatchers.set(dir, watcher);
- }
-
- /**
- * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's
- * working directory has LSP servers configured. The hook touches the
- * just-written file through the LSP and returns a formatted diagnostics
- * block (the written file's errors first, then a small cap of other-file
- * errors) — opencode's diagnostics-on-write pattern. Returns `undefined`
- * when no server matches, so writes stay zero-overhead for non-LSP files.
- */
- private buildAfterWriteHook(
- workingDirectory: string,
- servers: ResolvedLspServer[],
- ): ((absolutePath: string) => Promise<string>) | undefined {
- if (servers.length === 0) return undefined;
- const manager = this.lspManager;
- return async (absolutePath: string): Promise<string> => {
- if (!manager.hasServerForFile(absolutePath, servers)) return "";
- await manager.touchFile({
- file: absolutePath,
- root: workingDirectory,
- servers,
- mode: "document",
- });
- const diagnostics = manager.getDiagnostics({
- root: workingDirectory,
- servers,
- file: absolutePath,
- });
- let output = "";
- let otherFileCount = 0;
- for (const [file, issues] of Object.entries(diagnostics)) {
- const current = file === absolutePath;
- if (!current && otherFileCount >= MAX_LSP_OTHER_FILE_DIAGNOSTICS) continue;
- const block = reportDiagnostics(file, issues);
- if (!block) continue;
- if (current) {
- output += `${output ? "\n\n" : ""}LSP errors detected in this file, please fix:\n${block}`;
- } else {
- otherFileCount++;
- output += `${output ? "\n\n" : ""}LSP errors detected in other files:\n${block}`;
- }
- }
- return output;
- };
- }
-
- private _initModelRegistry(config: DispatchConfig): void {
- if (config.keys) {
- if (this.modelRegistry) {
- this.modelRegistry.updateConfig(config.keys);
- } else {
- this.modelRegistry = new ModelRegistry(config.keys);
- }
- } else {
- this.modelRegistry = null;
- }
- }
-
- getPermissionManager(): PermissionManager | undefined {
- return this.permissionManager;
- }
-
- /** Get the TaskList for a specific tab (creates the tab entry if missing). */
- getTaskList(tabId: string): TaskList {
- return this._getOrCreateTabAgent(tabId).taskList;
- }
-
- getClaudeAccounts(): ClaudeAccount[] {
- return this.claudeAccounts;
- }
-
- /** Get or create the TabAgent entry for a tab (without creating an Agent). */
- private _getOrCreateTabAgent(tabId: string): TabAgent {
- let tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) {
- const taskList = new TaskList();
- taskList.onChange((tasks) => {
- this.emit({ type: "task-list-update", tasks }, tabId);
- });
- tabAgent = {
- agent: null,
- status: "idle",
- keyId: null,
- modelId: null,
- taskList,
- messageQueue: [],
- queueListeners: [],
- shellStore: new BackgroundShellStore(),
- transcriptStore: new BackgroundTranscriptStore(),
- currentChunks: null,
- currentAssistantId: null,
- currentTurnId: null,
- autoWakeBudget: MAX_AGENT_AUTO_WAKES,
- };
- this.tabAgents.set(tabId, tabAgent);
- }
- return tabAgent;
- }
-
- private async getOrCreateAgentForTab(
- tabId: string,
- keyId?: string,
- modelId?: string,
- ): Promise<Agent> {
- const tabAgent = this._getOrCreateTabAgent(tabId);
-
- // Determine effective override: use provided values, or fall back to stored per-tab values
- const effectiveKeyId = keyId ?? tabAgent.keyId;
- const effectiveModelId = modelId ?? tabAgent.modelId;
-
- // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask, web=ask, youtube=ask)
- const permRead = getSetting("perm_read") !== "ask";
- const permEdit = getSetting("perm_edit") === "allow";
- const permBash = getSetting("perm_bash") === "allow";
- const permSummon = getSetting("perm_summon") === "allow";
- const permUserAgent = getSetting("perm_user_agent") === "allow";
- const permSendToTab = getSetting("perm_send_to_tab") === "allow";
- const permReadTab = getSetting("perm_read_tab") === "allow";
- const permWebSearch = getSetting("perm_web_search") === "allow";
- const permSearchCode = getSetting("perm_search_code") === "allow";
- const permKeyUsage = getSetting("perm_key_usage") === "allow";
- const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow";
- const permLsp = getSetting("perm_lsp") === "allow";
- const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permKeyUsage}:${permLsp}:${sysPrompt}`;
-
- // If the override differs or permissions changed, invalidate the cached agent
- if (
- tabAgent.agent &&
- (effectiveKeyId !== tabAgent.keyId ||
- effectiveModelId !== tabAgent.modelId ||
- permKey !== tabAgent._lastPermKey)
- ) {
- tabAgent.agent = null;
- }
-
- if (!tabAgent.agent) {
- const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- let workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir;
-
- // Expand ~ to home directory
- if (workingDirectory === "~" || workingDirectory.startsWith("~/")) {
- const { homedir } = await import("node:os");
- const { join } = await import("node:path");
- workingDirectory = join(homedir(), workingDirectory.slice(1));
- }
-
- // Resolve relative paths against the default working directory
- // (e.g. subagent cwd "./subtask" resolves relative to the parent's effective dir)
- {
- const { isAbsolute, resolve } = await import("node:path");
- if (!isAbsolute(workingDirectory)) {
- workingDirectory = resolve(defaultWorkDir, workingDirectory);
- }
- }
-
- // Auto-create the working directory if it doesn't exist
- try {
- const { mkdirSync, existsSync } = await import("node:fs");
- if (!existsSync(workingDirectory)) {
- mkdirSync(workingDirectory, { recursive: true });
- }
- } catch {
- // Ignore — tool execution will surface the error naturally
- }
-
- // Resolve LSP servers for this working directory once (cached).
- // Drives both diagnostics-on-write (the write_file hook) and the
- // optional `lsp` tool. Empty for directories with no `[lsp]` block.
- const lspServers = this.getLspServersForDir(workingDirectory);
- const afterWriteHook = this.buildAfterWriteHook(workingDirectory, lspServers);
-
- // Build tools list — child agents use their toolsOverride whitelist,
- // parent agents use permission settings from DB
- const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = [];
-
- if (tabAgent.toolsOverride) {
- // Child agent: use explicit tool whitelist
- const allowed = new Set(tabAgent.toolsOverride);
- if (allowed.has("read_file")) {
- toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
- // read_file_slice is a companion to read_file — only useful for
- // inspecting long lines that read_file truncated. Ship them together.
- toolEntries.push({
- name: "read_file_slice",
- tool: createReadFileSliceTool(workingDirectory),
- });
- // list_files is bundled with read access
- if (allowed.has("list_files")) {
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- }
- if (allowed.has("list_files") && !allowed.has("read_file")) {
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- if (allowed.has("write_file")) {
- toolEntries.push({
- name: "write_file",
- tool: createWriteFileTool(workingDirectory, afterWriteHook),
- });
- }
- if (allowed.has("run_shell")) {
- toolEntries.push({
- name: "run_shell",
- tool: createRunShellTool(workingDirectory, tabAgent.shellStore),
- });
- }
- if (allowed.has("search_code")) {
- toolEntries.push({
- name: "search_code",
- tool: createSearchCodeTool(workingDirectory),
- });
- }
- if (allowed.has("web_search")) {
- toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
- }
- if (allowed.has("key_usage")) {
- toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() });
- }
- if (allowed.has("lsp") && lspServers.length > 0) {
- toolEntries.push({
- name: "lsp",
- tool: createLspTool(() => ({
- manager: this.lspManager,
- workingDirectory,
- servers: lspServers,
- })),
- });
- }
- if (allowed.has("youtube_transcribe")) {
- toolEntries.push({
- name: "youtube_transcribe",
- tool: createYoutubeTranscribeTool(tabAgent.transcriptStore),
- });
- }
- if (allowed.has("todo")) {
- toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
- }
- if (allowed.has("summon")) {
- const childParentAllowedTools = new Set(toolEntries.map((e) => e.name));
- const allAgentDefs = loadAgents(workingDirectory);
- const availableSubagents = toAvailableSubagents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const availableUserAgents = toAvailableUserAgents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const agentDirPaths = getAgentDirPaths(workingDirectory);
- toolEntries.push({
- name: "summon",
- tool: createSummonTool(
- workingDirectory,
- {
- spawn: (opts) =>
- this.spawnChildAgent({
- ...opts,
- parentKeyId: tabAgent.keyId,
- parentModelId: tabAgent.modelId,
- parentAllowedTools: childParentAllowedTools,
- parentTabId: tabId,
- }),
- getResult: (id) => this.getChildResult(id),
- },
- availableSubagents,
- availableUserAgents,
- agentDirPaths,
- permUserAgent,
- ),
- });
- }
- if (allowed.has("retrieve")) {
- toolEntries.push({
- name: "retrieve",
- tool: createRetrieveTool({
- getResult: (id) =>
- tabAgent.shellStore.has(id)
- ? tabAgent.shellStore.getResult(id)
- : tabAgent.transcriptStore.has(id)
- ? tabAgent.transcriptStore.getResult(id)
- : this.getChildResult(id),
- }),
- });
- }
- // Tab-to-tab communication — gated on the child whitelist.
- if (allowed.has("send_to_tab") || allowed.has("read_tab")) {
- for (const entry of this.buildTabCommToolEntries(tabId, allowed.has("read_tab"))) {
- if (allowed.has(entry.name)) toolEntries.push(entry);
- }
- }
- } else {
- // Parent agent: use permission settings from DB
- if (permRead) {
- toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
- toolEntries.push({
- name: "read_file_slice",
- tool: createReadFileSliceTool(workingDirectory),
- });
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- if (permEdit) {
- toolEntries.push({
- name: "write_file",
- tool: createWriteFileTool(workingDirectory, afterWriteHook),
- });
- }
- if (permBash) {
- toolEntries.push({
- name: "run_shell",
- tool: createRunShellTool(workingDirectory, tabAgent.shellStore),
- });
- }
- if (permSearchCode) {
- toolEntries.push({
- name: "search_code",
- tool: createSearchCodeTool(workingDirectory),
- });
- }
- if (permWebSearch) {
- toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
- }
- if (permKeyUsage) {
- toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() });
- }
- // The `lsp` tool exposes diagnostics + navigation on demand. It is
- // gated by `perm_lsp` AND requires at least one server configured
- // in the working directory's `dispatch.toml`.
- if (permLsp && lspServers.length > 0) {
- toolEntries.push({
- name: "lsp",
- tool: createLspTool(() => ({
- manager: this.lspManager,
- workingDirectory,
- servers: lspServers,
- })),
- });
- }
- if (permYoutubeTranscribe) {
- toolEntries.push({
- name: "youtube_transcribe",
- tool: createYoutubeTranscribeTool(tabAgent.transcriptStore),
- });
- }
- toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
- // The `summon` tool is registered when EITHER the subagent
- // permission (`perm_summon`) OR the user-agent permission
- // (`perm_user_agent`) is granted — the two are independent.
- // `perm_summon` enables ordinary subagent spawning; granting
- // only `perm_user_agent` exposes summon in user-agent-only mode
- // (spawns top-level user agents exclusively).
- if (permSummon || permUserAgent) {
- // Capture parent's allowed tool names for child permission enforcement
- const parentAllowedTools = new Set(toolEntries.map((e) => e.name));
- const allAgentDefs = loadAgents(workingDirectory);
- const availableSubagents = toAvailableSubagents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const availableUserAgents = toAvailableUserAgents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const agentDirPaths = getAgentDirPaths(workingDirectory);
- toolEntries.push({
- name: "summon",
- tool: createSummonTool(
- workingDirectory,
- {
- spawn: (opts) =>
- this.spawnChildAgent({
- ...opts,
- parentKeyId: tabAgent.keyId,
- parentModelId: tabAgent.modelId,
- parentAllowedTools,
- parentTabId: tabId,
- }),
- getResult: (id) => this.getChildResult(id),
- },
- availableSubagents,
- availableUserAgents,
- agentDirPaths,
- permUserAgent,
- permSummon,
- ),
- });
- // `retrieve` collects subagent results. User agents are
- // fire-and-forget, so it is bundled with the subagent
- // permission only — a user-agent-only grant doesn't get it.
- if (permSummon) {
- toolEntries.push({
- name: "retrieve",
- tool: createRetrieveTool({
- getResult: (id) =>
- tabAgent.shellStore.has(id)
- ? tabAgent.shellStore.getResult(id)
- : tabAgent.transcriptStore.has(id)
- ? tabAgent.transcriptStore.getResult(id)
- : this.getChildResult(id),
- }),
- });
- }
- }
- if (permSendToTab || permReadTab) {
- const tabCommAllowed = new Set<string>();
- if (permSendToTab) tabCommAllowed.add("send_to_tab");
- if (permReadTab) tabCommAllowed.add("read_tab");
- for (const entry of this.buildTabCommToolEntries(tabId, permReadTab)) {
- if (tabCommAllowed.has(entry.name)) toolEntries.push(entry);
- }
- }
- }
-
- const tools = toolEntries.map((e) => e.tool);
- const toolNames = toolEntries.map((e) => e.name);
- tabAgent._lastPermKey = permKey;
-
- const ruleset = configToRuleset(this.config);
-
- // Try to resolve model from registry, fall back to env vars
- let apiKey = "";
- let model = "deepseek-v4-flash";
- let baseURL = "https://opencode.ai/zen/go/v1";
- let provider: string | undefined;
- let claudeCredentials: { accessToken: string } | undefined;
-
- let useOverride = false;
-
- if (effectiveKeyId && effectiveModelId && this.modelRegistry) {
- // Direct override: look up the key by id in the registry
- const keyState = this.modelRegistry
- .getKeys()
- .find((k) => k.definition.id === effectiveKeyId);
- if (keyState) {
- const key = keyState.definition;
- if (key.provider === "anthropic") {
- // Anthropic provider: resolve credentials from Claude accounts
- const credFile = key.credentials_file;
- const findAccount = () =>
- this.claudeAccounts.find((a) => a.id === effectiveKeyId) ??
- (credFile
- ? this.claudeAccounts.find((a) => a.source === credFile)
- : this.claudeAccounts[0]);
- let account = findAccount();
- // Self-heal: account discovery runs once at construction and can
- // fail at boot (e.g. the data dir isn't mounted yet and
- // getDatabase() throws EACCES), leaving claudeAccounts empty for
- // the process lifetime. If the lookup fails, re-run discovery now
- // that the DB is reachable and retry before giving up.
- if (!account) {
- this._refreshClaudeAccounts();
- account = findAccount();
- }
- if (account) {
- const creds = refreshAccountCredentials(account);
- if (creds && creds.expiresAt > Date.now() + 60_000) {
- claudeCredentials = { accessToken: creds.accessToken };
- apiKey = creds.accessToken;
- baseURL = key.base_url;
- model = effectiveModelId;
- provider = "anthropic";
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- } else {
- // Token expired — await the async refresh
- const fresh = await refreshAccountCredentialsAsync(account);
- if (fresh && fresh.expiresAt > Date.now() + 60_000) {
- account.credentials = fresh;
- claudeCredentials = { accessToken: fresh.accessToken };
- apiKey = fresh.accessToken;
- baseURL = key.base_url;
- model = effectiveModelId;
- provider = "anthropic";
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- } else {
- console.warn(
- `dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`,
- );
- claudeCredentials = { accessToken: account.credentials.accessToken };
- apiKey = account.credentials.accessToken;
- baseURL = key.base_url;
- model = effectiveModelId;
- provider = "anthropic";
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- }
- }
- } else {
- console.warn(`dispatch: no Claude credentials found for key "${key.id}"`);
- }
- } else {
- // Standard key: resolve from env var
- const envKey = resolveApiKey(key.id, key.env);
- if (envKey) {
- apiKey = envKey;
- baseURL = key.base_url;
- model = effectiveModelId;
- // OpenCode Go splits its catalog across two endpoints:
- // `/chat/completions` — GLM, Kimi, DeepSeek, MiMo (OpenAI-compatible)
- // `/messages` — MiniMax, Qwen (Anthropic-format)
- // The configured key has provider="opencode-go" which defaults to
- // the OpenAI-compatible path. When the selected model lives on the
- // `/messages` route, route through the API-key Anthropic provider
- // instead so the SDK targets the correct endpoint and protocol.
- if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(model)) {
- provider = "opencode-anthropic";
- }
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- } else {
- console.warn(
- `dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`,
- );
- // Apply the correct model + baseURL even when the key
- // is unavailable so the request at least targets the
- // right endpoint and produces a diagnosable auth error
- // instead of silently routing to the default OpenCode Go
- // endpoint (which may serve a different model).
- baseURL = key.base_url;
- model = effectiveModelId;
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- }
- }
- } else {
- console.warn(`dispatch: key "${effectiveKeyId}" not found in model registry`);
- }
- }
-
- if (!useOverride) {
- // Clear any previous override when falling back to default resolution
- tabAgent.keyId = null;
- tabAgent.modelId = null;
- }
-
- const customSystemPrompt = getSetting("system_prompt") || undefined;
- tabAgent.agent = new Agent(
- {
- model,
- apiKey,
- baseURL,
- systemPrompt: buildSystemPrompt(toolNames, customSystemPrompt),
- tools,
- workingDirectory,
- permissionChecker: this.permissionManager ?? undefined,
- ruleset,
- provider,
- tabId,
- ...(claudeCredentials ? { claudeCredentials } : {}),
- },
- {
- dequeueMessages: () => this.dequeueMessages(tabId),
- waitForQueuedMessage: () => this.waitForQueuedMessage(tabId),
- },
- );
-
- // Pre-populate the Agent's in-memory message history from the DB
- // so prior turns survive Agent recreation. The Agent is
- // constructed fresh here in three scenarios that ALL discard
- // the previous in-memory `messages` array:
- // 1. First call for this tab (no prior Agent existed)
- // 2. Model/key/permission/working-directory change — the
- // invalidation gate above set `tabAgent.agent = null`.
- // This is the model-switcher-slider case: without this
- // pre-population, DeepSeek would see zero context after
- // switching from Opus mid-conversation.
- // 3. Config or skills reload (configWatcher / skillsWatcher
- // also null out `tabAgent.agent`).
- //
- // Boundary semantics: `processMessage` appends the current turn's
- // user message (as a chunk row) BEFORE calling this function, so the
- // grouped history ends in `[..., u_current]`. In the fallback retry
- // path the previous attempt may also have flushed a partial assistant
- // turn, so it can end `[..., u_current, partial_a]`. Either way, we
- // walk backwards to the most recent user-role message and load only
- // strictly-prior messages: `agent.run()` pushes the current user
- // message itself, so including it here would duplicate it.
- //
- // `toModelMessages` already filters out `role === "system"`
- // rows and strips `error` / `system` chunks, so it's safe to
- // load system messages verbatim.
- try {
- const rows = getMessagesForTab(tabId);
- let cutIdx = rows.length;
- for (let i = rows.length - 1; i >= 0; i--) {
- const row = rows[i];
- if (row && row.role === "user") {
- cutIdx = i;
- break;
- }
- }
- if (cutIdx > 0) {
- tabAgent.agent.messages = rows
- .slice(0, cutIdx)
- .map((r) => ({ role: r.role, chunks: r.chunks }));
- }
- } catch {
- // DB read failed — leave `messages: []`. The agent still
- // works, just without prior history (matches pre-fix
- // behaviour, so this is no worse than what we had before).
- }
- }
- return tabAgent.agent;
- }
-
- /**
- * Resolve connection parameters (apiKey / baseURL / model / provider /
- * Claude OAuth credentials) for a key+model pair WITHOUT mutating any tab
- * state. Mirrors the resolution in `getOrCreateAgentForTab` (Anthropic
- * account refresh, env-var keys, OpenCode-Go anthropic-route detection) but
- * is side-effect-free so it can be reused by compaction. Returns `null` when
- * the key/model can't be resolved to a usable connection.
- */
- private async resolveConnection(
- keyId: string,
- modelId: string,
- ): Promise<{
- apiKey: string;
- baseURL: string;
- model: string;
- provider?: string;
- claudeCredentials?: { accessToken: string };
- } | null> {
- if (!keyId || !modelId || !this.modelRegistry) return null;
- const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === keyId);
- if (!keyState) return null;
- const key = keyState.definition;
-
- if (key.provider === "anthropic") {
- const credFile = key.credentials_file;
- const findAccount = () =>
- this.claudeAccounts.find((a) => a.id === keyId) ??
- (credFile
- ? this.claudeAccounts.find((a) => a.source === credFile)
- : this.claudeAccounts[0]);
- let account = findAccount();
- if (!account) {
- this._refreshClaudeAccounts();
- account = findAccount();
- }
- if (!account) return null;
- let creds = refreshAccountCredentials(account);
- if (!creds || creds.expiresAt <= Date.now() + 60_000) {
- const fresh = await refreshAccountCredentialsAsync(account);
- if (fresh) {
- account.credentials = fresh;
- creds = fresh;
- }
- }
- const accessToken = creds?.accessToken ?? account.credentials.accessToken;
- return {
- apiKey: accessToken,
- baseURL: key.base_url,
- model: modelId,
- provider: "anthropic",
- claudeCredentials: { accessToken },
- };
- }
-
- // Standard key resolved from env var.
- const envKey = resolveApiKey(key.id, key.env);
- if (!envKey) return null;
- let provider: string | undefined;
- if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(modelId)) {
- provider = "opencode-anthropic";
- }
- return { apiKey: envKey, baseURL: key.base_url, model: modelId, provider };
- }
-
- /**
- * Resolve the compactor model: the configured `compaction_model_*` setting
- * when present, otherwise fall back to the source tab's own key+model. Used
- * to run the summary generation request.
- */
- private resolveCompactorKeyModel(sourceTabId: string): { keyId: string; modelId: string } | null {
- const cfgKey = getSetting("compaction_model_key_id");
- const cfgModel = getSetting("compaction_model_id");
- if (cfgKey && cfgModel) return { keyId: cfgKey, modelId: cfgModel };
- const tabAgent = this.tabAgents.get(sourceTabId);
- const row = getTab(sourceTabId);
- const keyId = tabAgent?.keyId ?? row?.keyId ?? null;
- const modelId = tabAgent?.modelId ?? row?.modelId ?? null;
- if (keyId && modelId) return { keyId, modelId };
- return null;
- }
-
- /**
- * Run a one-shot, tool-less summary generation using a transient Agent. The
- * Agent loop handles Claude-OAuth billing/identity/caching correctly. The
- * prompt is the entire summary request (transcript + template); no tools are
- * registered so the model can only produce text. Returns the concatenated
- * assistant text, or throws on error/abort.
- */
- private async generateSummary(
- conn: {
- apiKey: string;
- baseURL: string;
- model: string;
- provider?: string;
- claudeCredentials?: { accessToken: string };
- },
- prompt: string,
- abortSignal: AbortSignal,
- ): Promise<string> {
- const agent = new Agent({
- model: conn.model,
- apiKey: conn.apiKey,
- baseURL: conn.baseURL,
- systemPrompt:
- "You are a conversation-summarization assistant. Follow the user's instructions and output ONLY the requested Markdown summary.",
- tools: [],
- workingDirectory: process.env.DISPATCH_WORKING_DIR ?? process.cwd(),
- provider: conn.provider,
- ...(conn.claudeCredentials ? { claudeCredentials: conn.claudeCredentials } : {}),
- });
- let out = "";
- let errored: string | null = null;
- for await (const event of agent.run(prompt, { abortSignal })) {
- if (abortSignal.aborted) break;
- if (event.type === "text-delta") out += event.delta;
- else if (event.type === "error") errored = event.error;
- }
- if (abortSignal.aborted) throw new Error("Compaction cancelled");
- if (errored) throw new Error(errored);
- const trimmed = out.trim();
- if (!trimmed) throw new Error("Compaction produced an empty summary");
- return trimmed;
- }
-
- /**
- * Compact a conversation (UI-driven). Summarizes the older "head" of
- * `sourceTabId` into an anchored Markdown summary while preserving the last
- * N turns verbatim, then performs the id-relocation the product requires:
- *
- * - The FULL pre-compaction history is moved to a fresh `backupTabId`
- * (so nothing is destroyed — fully reversible).
- * - `sourceTabId` (the canonical id, with its key/model/working-dir/agent
- * and the global tool permissions intact) is re-seeded with the summary
- * turn + the preserved tail.
- *
- * `tempTabId` is the frontend placeholder tab hosting the "compacting…"
- * message; it is discarded on completion. Cancellation = the caller aborts
- * via `tempTabId`'s abort controller (e.g. closing the placeholder tab).
- *
- * Returns when the compaction settles; emits `compaction-started`,
- * `compaction-complete`, or `compaction-error`.
- */
- async compactTab(tempTabId: string, sourceTabId: string): Promise<void> {
- const tempAgent = this._getOrCreateTabAgent(tempTabId);
- const abortController = new AbortController();
- tempAgent.abortController = abortController;
-
- const fail = (error: string): void => {
- const src = this.tabAgents.get(sourceTabId);
- if (src) src.compacting = false;
- this.emit({ type: "compaction-error", tempTabId, sourceTabId, error }, tempTabId);
- // Drain anything queued on the source while it was locked.
- this.continueFromQueue(sourceTabId);
- };
-
- try {
- // Refuse to compact a running tab (turn must have ended).
- if (this.getTabStatus(sourceTabId) === "running") {
- fail("Cannot compact while a turn is in progress.");
- return;
- }
-
- // Lock the source so new messages queue instead of starting turns.
- const sourceAgent = this._getOrCreateTabAgent(sourceTabId);
- sourceAgent.compacting = true;
- this.emit({ type: "compaction-started", tempTabId, sourceTabId }, tempTabId);
-
- // Read the full history as grouped messages (preserves turnId/seq).
- const rows = groupRowsToMessages(getChunksForTab(sourceTabId));
- const { tail, prompt } = buildCompactionRequest({ messages: rows });
- if (!prompt) {
- fail("Not enough conversation history to compact.");
- return;
- }
-
- // Resolve the compactor model (configured, else source tab's own).
- const compactor = this.resolveCompactorKeyModel(sourceTabId);
- if (!compactor) {
- fail("No model available to run compaction. Configure a compaction model in Settings.");
- return;
- }
- const conn = await this.resolveConnection(compactor.keyId, compactor.modelId);
- if (!conn) {
- fail("Could not resolve credentials for the compaction model.");
- return;
- }
-
- // Generate the summary (abortable).
- const summary = await this.generateSummary(conn, prompt, abortController.signal);
- if (abortController.signal.aborted) {
- fail("Compaction cancelled");
- return;
- }
-
- // Relocate the FULL history to a backup tab, then re-seed the source.
- const sourceRow = getTab(sourceTabId);
- const backupTabId = crypto.randomUUID();
- const baseTitle = sourceRow?.title ?? "Conversation";
- const backupTitle = `${baseTitle} (pre-compaction)`;
- createTab(backupTabId, backupTitle, {
- keyId: sourceRow?.keyId ?? null,
- modelId: sourceRow?.modelId ?? null,
- });
- rekeyChunks(sourceTabId, backupTabId);
-
- // Re-seed the canonical (source) id: a summary user turn followed by
- // the preserved tail rows (turnId/step/role/type/data preserved).
- const summaryTurnId = crypto.randomUUID();
- appendChunks(sourceTabId, explodeUserText(summaryTurnId, buildSummaryTurnText(summary)));
- for (const msg of tail) {
- const drafts = explodeTurn(msg.turnId, msg.chunks);
- if (msg.role === "user") {
- // groupRowsToMessages collapses a user message to a single text
- // chunk; explodeTurn only handles assistant/system shapes, so
- // rebuild the user row explicitly.
- const text = msg.chunks.find((c) => c.type === "text");
- appendChunks(
- sourceTabId,
- explodeUserText(msg.turnId, text && text.type === "text" ? text.text : ""),
- );
- continue;
- }
- if (drafts.length > 0) appendChunks(sourceTabId, drafts);
- }
-
- // Reset the source Agent so its in-memory history reloads from the
- // freshly re-seeded chunk log on the next turn.
- sourceAgent.agent = null;
- sourceAgent.compacting = false;
-
- this.emit(
- { type: "compaction-complete", tempTabId, sourceTabId, backupTabId, backupTitle },
- sourceTabId,
- );
- // Drain any messages queued while the source was locked.
- this.continueFromQueue(sourceTabId);
- } catch (err) {
- if (abortController.signal.aborted) {
- fail("Compaction cancelled");
- return;
- }
- fail(err instanceof Error ? err.message : String(err));
- } finally {
- // The placeholder tab is transient; drop its in-memory agent state.
- this.tabAgents.delete(tempTabId);
- }
- }
-
- getTabStatus(tabId: string): AgentStatus {
- return this.tabAgents.get(tabId)?.status ?? "idle";
- }
-
- /**
- * Prompt-cache WARMING for an idle tab (see `Agent.warmCache`).
- *
- * Reconstructs the tab's genuine conversation from the persisted chunk log,
- * resolves the SAME agent (model/key/tools/system prompt) the next real turn
- * would use, and replays the exact cached prefix plus one trivial throwaway
- * turn so the provider's ~5-min prompt-cache TTL is refreshed. The warming
- * request and its response are NOT persisted, NOT emitted, and NOT folded
- * into the real usage aggregate — its `usage` is returned to the caller so a
- * warming-only "last request" cache rate can be shown without polluting the
- * real Cache Rate metric.
- *
- * Refuses to fire while the tab is generating (`running`): the prefix would
- * be mid-mutation and the request would contend with the live turn. Callers
- * gate on idle anyway; this is defence in depth.
- *
- * Returns `{ ok: true, usage }` on success or `{ ok: false, error }` so the
- * route can surface a debug-strip error string. Never throws.
- */
- async warmCacheForTab(
- tabId: string,
- opts: {
- keyId?: string;
- modelId?: string;
- agentModels?: AgentModelEntry[];
- reasoningEffort?: ReasoningEffort;
- } = {},
- ): Promise<{ ok: true; usage: UsageData } | { ok: false; error: string }> {
- if (this.getTabStatus(tabId) === "running") {
- return { ok: false, error: "tab is generating" };
- }
- try {
- const tabAgent = this._getOrCreateTabAgent(tabId);
- if (opts.agentModels) tabAgent.agentModels = opts.agentModels;
-
- // Resolve the agent the next REAL turn would use. The fallback chain's
- // first entry mirrors `processMessage`'s primary attempt; we only warm
- // the primary (warming a fallback model would write a DIFFERENT prefix).
- const fallbackSequence = this.buildFallbackSequence(tabAgent, opts.keyId, opts.modelId);
- const primary = fallbackSequence[0];
- const agent = await this.getOrCreateAgentForTab(
- tabId,
- primary?.key_id || opts.keyId,
- primary?.model_id || opts.modelId,
- );
-
- // Resolve the SAME reasoning effort the next real turn would use:
- // per-model (agent definition) → per-tab selector → Agent default.
- // This drives the thinking providerOptions, which is an Anthropic
- // message-cache key — warming MUST match it or it warms a different
- // cache bucket than the real turn reads (the 0%-on-switch bug).
- const effort = primary?.effort ?? opts.reasoningEffort;
-
- // Rebuild the genuine history exactly as `getOrCreateAgentForTab`'s
- // pre-population does, but keep the FULL history (no trailing-user
- // trim): warming replays the complete cached prefix as-is.
- let history: ChatMessage[] = [];
- try {
- history = getMessagesForTab(tabId).map((r) => ({ role: r.role, chunks: r.chunks }));
- } catch {
- // DB read failed — warm with whatever in-memory history the agent has.
- history = [...agent.messages];
- }
-
- const usage = await agent.warmCache(history, {
- ...(effort ? { reasoningEffort: effort } : {}),
- });
- return { ok: true, usage };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
- }
- }
-
- /**
- * Snapshot of every tab the manager is currently tracking. Sent on WS
- * connect and via GET /status so a freshly-loaded frontend can
- * reconstruct any in-flight assistant turn without missing the chunks
- * that arrived before its WS handshake completed.
- *
- * For each running tab, the snapshot includes:
- * - status: "running"
- * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks`
- * (the live chunk array the streaming loop appends to). The
- * consumer owns this copy and may mutate it freely.
- * - currentAssistantId: the DB id of the in-flight assistant message
- * row. The frontend aligns its local assistant message id with
- * this so the next `done` event lands on the right message.
- *
- * Every tab additionally carries its `tasks` (the current todo list) when
- * non-empty, so a reloaded frontend rehydrates the Tasks panel from the
- * backend rather than blanking it.
- *
- * For idle/error tabs, only `status` (plus any `tasks`) is present. Tabs not in
- * `this.tabAgents` (e.g. tabs in the DB that have never been touched
- * since server start) are absent from the returned record — the
- * caller infers their status from the DB row (always "idle" at rest).
- */
- getAllStatuses(): Record<string, TabStatusSnapshot> {
- const result: Record<string, TabStatusSnapshot> = {};
- for (const [tabId, tabAgent] of this.tabAgents.entries()) {
- const snap: TabStatusSnapshot = { status: tabAgent.status };
- // Include the tab's todo list (for ALL tabs, not just running ones)
- // so a reloaded frontend rehydrates the Tasks panel from the backend
- // instead of blanking it. Omit when empty to keep the payload lean.
- const tasks = tabAgent.taskList.getTasks();
- if (tasks.length > 0) {
- snap.tasks = tasks;
- }
- if (tabAgent.status === "running") {
- if (tabAgent.currentChunks) {
- // Defensive shallow copy: callers may serialize/mutate.
- snap.currentChunks = [...tabAgent.currentChunks];
- }
- if (tabAgent.currentAssistantId) {
- snap.currentAssistantId = tabAgent.currentAssistantId;
- }
- if (tabAgent.currentTurnId) {
- snap.currentTurnId = tabAgent.currentTurnId;
- }
- }
- result[tabId] = snap;
- }
- return result;
- }
-
- /** @deprecated Use getTabStatus(tabId) instead */
- getStatus(): AgentStatus {
- // Return running if any tab is running, otherwise idle
- for (const tabAgent of this.tabAgents.values()) {
- if (tabAgent.status === "running") return "running";
- }
- return "idle";
- }
-
- getMessageCount(): number {
- return this.messageCount;
- }
-
- onEvent(listener: (event: AgentEvent & { tabId: string }) => void): () => void {
- this.eventListeners.add(listener);
- return () => {
- this.eventListeners.delete(listener);
- };
- }
-
- private emit(event: AgentEvent, tabId: string): void {
- for (const listener of this.eventListeners) {
- listener({ ...event, tabId } as AgentEvent & { tabId: string });
- }
- }
-
- /**
- * Persist a system chunk (notice / model-changed / config-reload /
- * cancelled) to a tab's history.
- *
- * If an assistant turn is in flight (`currentChunks` is non-null), the
- * chunk is folded into the in-flight chunk list; it is exploded into a
- * `system` chunk row when the turn flushes.
- *
- * Otherwise we append a standalone `system` chunk row immediately. Adjacent
- * system rows are coalesced back into one system message at group time
- * (`groupRowsToMessages`).
- */
- private routeSystemEventToTab(tabId: string, kind: SystemChunkKind, text: string): void {
- const tabAgent = this.tabAgents.get(tabId);
-
- // Turn in flight → fold into the in-flight chunk list; it is exploded
- // into chunk rows (including this system chunk) when the turn flushes.
- if (tabAgent?.currentChunks) {
- tabAgent.currentChunks.push({ type: "system", kind, text });
- return;
- }
-
- // No turn in flight → persist a standalone system chunk row immediately.
- try {
- const turnId = tabAgent?.currentTurnId ?? crypto.randomUUID();
- appendChunks(tabId, explodeTurn(turnId, [{ type: "system", kind, text }]));
- } catch {
- // DB not available (e.g. tab not yet created) — drop silently.
- }
- }
-
- stopTab(tabId: string): void {
- const tabAgent = this.tabAgents.get(tabId);
- if (tabAgent) {
- // If a turn is in flight, drop a `cancelled` system chunk into the
- // in-flight chunk list so the user sees an explicit "Generation
- // cancelled by user" marker at the cancellation point. It is
- // persisted (as a chunk row) when `processMessage` flushes the
- // aborted turn.
- if (tabAgent.currentChunks) {
- tabAgent.currentChunks.push({
- type: "system",
- kind: "cancelled",
- text: "Generation cancelled by user",
- });
- }
- tabAgent.abortController?.abort();
- tabAgent.status = "idle";
- this.emit({ type: "status", status: "idle" }, tabId);
- tabAgent.agent = null;
- // Resolve any pending completion promise so retrieve doesn't hang
- tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." });
- }
- }
-
- deleteTab(tabId: string): void {
- this.stopTab(tabId);
- this.tabAgents.delete(tabId);
- // Drop any spilled tool-output files this tab accumulated. Best-effort —
- // errors are swallowed inside the helper. See packages/core/src/tools/truncate.ts.
- clearSpillForTab(tabId);
- }
-
- /**
- * Spawn a child agent in a new tab. Returns the tab ID (agent_id).
- * The child runs asynchronously — use getChildResult to await completion.
- */
- async spawnChildAgent(options: {
- task: string;
- tools: string[];
- workingDirectory?: string;
- /**
- * Optional slug of an `AgentDefinition` to apply. When set, the
- * definition's `tools`, `models`, and `cwd` take precedence over
- * the `tools`/`workingDirectory` passed in `options`. Tools are
- * still intersected with `parentAllowedTools` to prevent a
- * subagent from gaining capabilities its parent doesn't have.
- */
- agentSlug?: string;
- parentKeyId?: string | null;
- parentModelId?: string | null;
- parentAllowedTools?: Set<string>;
- parentTabId?: string;
- /**
- * When true, spawn as an independent top-level "user agent" tab
- * instead of a subagent child tab. User agents have no parent,
- * are persistent, and cannot be retrieved (fire-and-forget).
- */
- topLevel?: boolean;
- }): Promise<string> {
- const tabId = crypto.randomUUID();
- const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task;
-
- // Validate working directory is within the parent agent's effective CWD
- const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- let parentEffectiveDir = options.topLevel
- ? defaultWorkDir
- : options.parentTabId
- ? (this.tabAgents.get(options.parentTabId)?.workingDirectoryOverride ?? defaultWorkDir)
- : defaultWorkDir;
-
- // Expand ~ in parent dir
- if (parentEffectiveDir === "~" || parentEffectiveDir.startsWith("~/")) {
- const { homedir } = await import("node:os");
- const { join } = await import("node:path");
- parentEffectiveDir = join(homedir(), parentEffectiveDir.slice(1));
- }
-
- // Resolve the agent definition (if a slug was supplied) BEFORE
- // computing the effective working directory and tool whitelist.
- // The definition's cwd/tools take precedence over the caller's
- // `workingDirectory`/`tools` parameters, mirroring how a top-level
- // tab picking the same definition would behave.
- let agentDef: ReturnType<typeof loadAgent> = null;
- if (options.agentSlug) {
- agentDef = loadAgent(options.agentSlug, parentEffectiveDir);
- if (!agentDef) {
- const allDefs = loadAgents(parentEffectiveDir);
- if (options.topLevel) {
- const userAgents = allDefs
- .filter((d) => !d.is_subagent)
- .map((d) => `${d.slug} (${d.name})`);
- const hint =
- userAgents.length > 0
- ? ` Available user agents: ${userAgents.join(", ")}.`
- : " No user agent definitions exist yet.";
- throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
- } else {
- const subagents = allDefs
- .filter((d) => d.is_subagent)
- .map((d) => `${d.slug} (${d.name})`);
- const hint =
- subagents.length > 0
- ? ` Available subagents: ${subagents.join(", ")}.`
- : " No subagent definitions exist yet.";
- throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
- }
- }
-
- // Validate that the definition type matches the spawn mode:
- // subagent slugs can't be used with top_level=true, and
- // user-agent slugs can't be used without top_level=true.
- if (options.topLevel && agentDef.is_subagent) {
- throw new Error(
- `Cannot spawn user agent: "${options.agentSlug}" is a subagent definition. Use a non-subagent definition for top_level=true.`,
- );
- }
- if (!options.topLevel && !agentDef.is_subagent) {
- throw new Error(
- `Cannot spawn subagent: "${options.agentSlug}" is a user agent definition. Set top_level=true to spawn it as an independent tab, or use a subagent definition.`,
- );
- }
- }
-
- // Resolve child working directory.
- // Subagents are validated to stay within the parent's effective dir.
- // User agents (topLevel) are free to use any directory.
- const requestedDir = agentDef?.cwd ?? options.workingDirectory;
- let resolvedWorkingDirectory = requestedDir;
- if (requestedDir) {
- const { isAbsolute, relative, resolve, join } = await import("node:path");
- // Expand ~ in child working directory
- let childDir = requestedDir;
- if (childDir === "~" || childDir.startsWith("~/")) {
- const { homedir } = await import("node:os");
- childDir = join(homedir(), childDir.slice(1));
- }
- if (options.topLevel) {
- // User agents: resolve freely, no containment check
- resolvedWorkingDirectory = resolve(defaultWorkDir, childDir);
- } else {
- // Subagents: validate within parent's directory
- const parentDir = resolve(parentEffectiveDir);
- const resolved = resolve(parentDir, childDir);
- const rel = relative(parentDir, resolved);
- const isOutside = rel.startsWith("..") || isAbsolute(rel);
- if (isOutside) {
- throw new Error(
- `Working directory "${requestedDir}" is outside the parent's working directory "${parentDir}".`,
- );
- }
- // Store the resolved absolute path so downstream code doesn't
- // re-resolve against the wrong base directory
- resolvedWorkingDirectory = resolved;
- }
- }
-
- // Determine the child's tool whitelist. When an agent definition
- // was supplied, expand its short permission-group names
- // (read/edit/bash) into concrete tool names. Otherwise use the
- // `tools` parameter verbatim. Either way, intersect with
- // parentAllowedTools so a subagent can't gain capabilities the
- // parent doesn't have — even an agent definition can't escalate.
- const baseTools = agentDef ? expandAgentToolNames(agentDef.tools) : options.tools;
- let childTools = baseTools;
- if (options.parentAllowedTools) {
- childTools = baseTools.filter((t) => options.parentAllowedTools?.has(t));
- }
-
- // Create the tab agent entry with overrides
- const tabAgent = this._getOrCreateTabAgent(tabId);
- tabAgent.toolsOverride = childTools;
- tabAgent.workingDirectoryOverride = resolvedWorkingDirectory;
- tabAgent.finalOutput = "";
-
- const primary = agentDef?.models[0];
- if (agentDef && primary) {
- // The agent definition specifies its own model fallback chain.
- // Set keyId/modelId to the primary (first) model in the chain so
- // the frontend can display the concrete key/model this subagent
- // was configured with, while `agentModels` drives the fallback
- // sequence (matches how a top-level tab using this definition
- // would be configured).
- tabAgent.keyId = primary.key_id;
- tabAgent.modelId = primary.model_id;
- tabAgent.agentModels = agentDef.models;
- } else {
- // No definition (or definition has no models) → inherit from
- // the parent like before.
- tabAgent.keyId = options.parentKeyId ?? null;
- tabAgent.modelId = options.parentModelId ?? null;
- if (options.parentTabId) {
- const parentAgent = this.tabAgents.get(options.parentTabId);
- if (parentAgent?.agentModels) {
- tabAgent.agentModels = parentAgent.agentModels;
- }
- }
- }
-
- // Set up completion tracking — user agents are fire-and-forget,
- // so only subagents get completion promises.
- if (!options.topLevel) {
- tabAgent.completionPromise = new Promise((resolve) => {
- tabAgent.completionResolve = resolve;
- });
- }
-
- // Create tab in DB
- try {
- const { createTab } = await import("@dispatch/core");
- createTab(tabId, title, {
- keyId: tabAgent.keyId,
- modelId: tabAgent.modelId,
- parentTabId: options.topLevel ? undefined : options.parentTabId,
- });
- } catch {
- // Continue even if DB fails
- }
-
- // Notify the frontend about the new tab
- this.emit(
- {
- type: "tab-created",
- id: tabId,
- title,
- keyId: tabAgent.keyId,
- modelId: tabAgent.modelId,
- parentTabId: options.topLevel ? null : (options.parentTabId ?? null),
- agentSlug: options.agentSlug ?? null,
- workingDirectory: resolvedWorkingDirectory ?? null,
- agentModels: tabAgent.agentModels ?? null,
- },
- tabId,
- );
-
- // Start the child agent in the background
- this.processMessage(
- tabId,
- options.task,
- options.parentKeyId ?? undefined,
- options.parentModelId ?? undefined,
- ).catch((err) => {
- const errorMsg = err instanceof Error ? err.message : String(err);
- tabAgent.completionResolve?.({ status: "error", error: errorMsg });
- });
-
- return tabId;
- }
-
- /**
- * Wait for a child agent to finish and return its result.
- * Blocks until the child completes or errors.
- */
- async getChildResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const tabAgent = this.tabAgents.get(agentId);
- if (!tabAgent) {
- return { status: "error", error: `No agent found with id '${agentId}'` };
- }
-
- if (!tabAgent.completionPromise) {
- // Not a child agent or already completed
- if (tabAgent.status === "idle") {
- return { status: "done", result: tabAgent.finalOutput ?? "(no output)" };
- }
- if (tabAgent.status === "running") {
- return {
- status: "error",
- error:
- "This is a user agent (top-level tab) and cannot be retrieved. User agents are fire-and-forget.",
- };
- }
- return {
- status: "error",
- error: "Agent has no completion tracking. It may not have been spawned via summon.",
- };
- }
-
- return tabAgent.completionPromise;
- }
-
- // ─── Tab-to-tab communication ───────────────────────────────────
- //
- // `send_to_tab` / `read_tab` let an agent message a peer tab by its short
- // handle (a git-style prefix of the tab UUID). Delivery reuses the exact
- // running→queue / idle→new-turn routing that `POST /chat` uses (see
- // `deliverMessage`), so an agent message behaves identically to a user one.
-
- /**
- * Build the `key_usage` tool, wired to the live model registry (key states)
- * and the discovered Claude accounts. The tool fetches usage live with a
- * cache fallback (anthropic) or a live scrape (opencode-go), reporting
- * remaining headroom, reset times, and data freshness per key.
- */
- private buildKeyUsageTool(): ReturnType<typeof createKeyUsageTool> {
- return createKeyUsageTool({
- listKeys: () => this.modelRegistry?.getKeys() ?? [],
- listClaudeAccounts: () => this.claudeAccounts,
- });
- }
-
- /**
- * Build the `send_to_tab` + `read_tab` tool entries for `tabId`. Shared by
- * both tool-construction paths (child whitelist + permission-gated parent).
- * `selfHandle` is computed once so the calling tab can stamp provenance and
- * reject self-sends.
- *
- * `canReadTab` reflects whether THIS tab will also be granted `read_tab`
- * (the permissions are split). It is forwarded into `send_to_tab` so the
- * tool only points the agent at `read_tab` when it actually has it — never
- * advertising a tool the agent wasn't granted.
- */
- private buildTabCommToolEntries(
- tabId: string,
- canReadTab: boolean,
- ): Array<{ name: string; tool: ReturnType<typeof createSendToTabTool> }> {
- const selfHandle = shortestUniquePrefix(tabId);
- return [
- {
- name: "send_to_tab",
- tool: createSendToTabTool({
- resolveShortId: (prefix) => this.resolveTabHandle(prefix),
- // origin: "agent" subjects this to the receiver's auto-wake
- // budget so agent↔agent loops are bounded (see deliverMessage).
- deliver: (targetId, message) =>
- this.deliverMessage(targetId, message, { origin: "agent" }),
- listOpenHandles: () => this.listOpenHandles(tabId),
- self: { id: tabId, handle: selfHandle },
- canReadTab,
- }),
- },
- {
- name: "read_tab",
- tool: createReadTabTool({
- resolveShortId: (prefix) => this.resolveTabHandle(prefix),
- getLastResponse: (targetId) => this.getLastTabResponse(targetId),
- listOpenHandles: () => this.listOpenHandles(tabId),
- }),
- },
- ];
- }
-
- /**
- * Project a core `ResolveTabPrefixResult` down to the tool-facing
- * `TabResolution` (minimal `{ id, title, handle }` refs). Each match's
- * `handle` is recomputed via `shortestUniquePrefix` so the value the tool
- * echoes back always matches what the UI currently shows.
- */
- private resolveTabHandle(prefix: string): TabResolution {
- const res = resolveTabPrefix(prefix);
- if (res.status === "none") return { status: "none" };
- if (res.status === "ok") {
- return {
- status: "ok",
- tab: {
- id: res.tab.id,
- title: res.tab.title,
- handle: shortestUniquePrefix(res.tab.id),
- },
- };
- }
- return {
- status: "ambiguous",
- matches: res.matches.map((t) => ({
- id: t.id,
- title: t.title,
- handle: shortestUniquePrefix(t.id),
- })),
- };
- }
-
- /** Snapshot of open tabs as `{ handle, title }`, excluding `exceptId`
- * (typically the caller's own tab). Drives the "available tabs" hints. */
- private listOpenHandles(exceptId?: string): Array<{ handle: string; title: string }> {
- return listOpenTabs()
- .filter((t) => t.id !== exceptId)
- .map((t) => ({ handle: shortestUniquePrefix(t.id), title: t.title }));
- }
-
- /**
- * Return a tab's most recent COMPLETED assistant turn as flat text, plus
- * its current status. Reads the persisted chunk log (source of truth) and
- * grabs the last `role === "assistant"` group's text chunks. `text` is null
- * when no completed assistant turn exists yet.
- */
- getLastTabResponse(tabId: string): { text: string | null; status: AgentStatus } {
- const status = this.getTabStatus(tabId);
- try {
- const messages = getMessagesForTab(tabId);
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (!msg || msg.role !== "assistant") continue;
- const text = msg.chunks
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
- .map((c) => c.text)
- .join("")
- .trim();
- if (text.length > 0) return { text, status };
- }
- } catch {
- // DB unavailable / tab unknown — fall through to null.
- }
- return { text: null, status };
- }
-
- /**
- * Deliver `message` to `tabId`, choosing the SAME routing as `POST /chat`:
- * - target running → queue it (consumed like a user interrupt).
- * - target idle/errored → wake it and start a new turn.
- *
- * Returns quickly; does NOT block on the turn. Both the HTTP `/chat` path
- * and the `send_to_tab` tool call through here so the running/idle decision
- * lives in exactly one place.
- *
- * `opts` carries the per-request knobs `/chat` forwards (key/model, agent
- * fallback chain, reasoning effort, working dir, an explicit queue id). The
- * `send_to_tab` tool passes none of these — for a cold wake (a tab not in
- * `tabAgents`, e.g. after a server restart) the key/model are hydrated from
- * the live `TabAgent` if present, else from the persisted tab row. (A cold
- * tab keeps its stored key/model but not its full agent-definition fallback
- * chain — see plan notes.)
- */
- deliverMessage(
- tabId: string,
- message: string,
- opts: {
- keyId?: string;
- modelId?: string;
- agentModels?: AgentModelEntry[];
- reasoningEffort?: ReasoningEffort;
- workingDirectory?: string;
- queueId?: string;
- /**
- * Ephemeral ordered multimodal content (image/pdf attachments) for a
- * FRESH human turn. Forwarded to `processMessage` → `agent.run` only
- * when the tab is idle (a started turn); never carried into the queue
- * path (attachments require a fresh turn — the caller guards that).
- */
- content?: UserContentPart[];
- /**
- * Who is sending this message. `"human"` (default) is unrestricted
- * and REFILLS the target's agent-to-agent auto-wake budget. `"agent"`
- * (from the `send_to_tab` tool) is governed by that budget: an
- * agent-originated wake of an idle tab consumes one unit, and once the
- * budget is exhausted the message is queued WITHOUT starting a turn
- * (returned as `suppressed`) so a runaway A↔B loop can't spend tokens
- * forever with no human in the loop.
- */
- origin?: "human" | "agent";
- } = {},
- ): { status: "queued"; messageId: string } | { status: "started" } | { status: "suppressed" } {
- const origin = opts.origin ?? "human";
-
- // A human touching the tab clears any accumulated agent-wake throttle:
- // the conversation is back under human supervision, so peers get a fresh
- // budget of auto-wakes again.
- if (origin === "human") {
- this._getOrCreateTabAgent(tabId).autoWakeBudget = MAX_AGENT_AUTO_WAKES;
- }
-
- if (this.getTabStatus(tabId) === "running") {
- // Busy target → always queue (consumed like a user interrupt),
- // regardless of origin. Queuing does not itself start a turn, so it
- // can't drive a runaway loop; we don't spend budget here.
- const { messageId } = this.queueMessage(tabId, message, opts.queueId);
- return { status: "queued", messageId };
- }
-
- // Tab is mid-compaction → hold the message (queue, never start a turn)
- // until compaction settles. continueFromQueue (called after compaction)
- // drains it onto the compacted continuation.
- if (this.tabAgents.get(tabId)?.compacting) {
- const { messageId } = this.queueMessage(tabId, message, opts.queueId);
- return { status: "queued", messageId };
- }
-
- // Idle/errored target → this delivery would WAKE the tab (start a turn).
- // For agent-originated wakes, enforce the auto-wake budget first.
- if (origin === "agent") {
- const target = this._getOrCreateTabAgent(tabId);
- if (target.autoWakeBudget <= 0) {
- // Budget exhausted: preserve the message (queue it, never drop)
- // but do NOT wake the tab. A human message will refill the budget
- // and the queued message will be seen on the next human turn.
- this.queueMessage(tabId, message, opts.queueId);
- const notice =
- `Automatic agent-to-agent message limit reached for this tab ` +
- `(${MAX_AGENT_AUTO_WAKES} consecutive). Further messages from other tabs ` +
- `are held until you send a message here.`;
- this.emit({ type: "notice", message: notice }, tabId);
- this.routeSystemEventToTab(tabId, "notice", notice);
- return { status: "suppressed" };
- }
- target.autoWakeBudget -= 1;
- }
-
- // Resolve key/model: explicit opts win, then the live tab agent's, then
- // the persisted row's.
- const tabAgent = this.tabAgents.get(tabId);
- let keyId = opts.keyId ?? tabAgent?.keyId ?? undefined;
- let modelId = opts.modelId ?? tabAgent?.modelId ?? undefined;
- const agentModels = opts.agentModels ?? tabAgent?.agentModels;
- if (!keyId || !modelId) {
- const row = getTab(tabId);
- if (row) {
- keyId = keyId ?? row.keyId ?? undefined;
- modelId = modelId ?? row.modelId ?? undefined;
- }
- }
-
- this.processMessage(
- tabId,
- message,
- keyId,
- modelId,
- opts.reasoningEffort,
- opts.workingDirectory,
- agentModels,
- opts.content,
- ).catch((err) => {
- console.error(`[dispatch] deliverMessage processMessage error for tab ${tabId}:`, err);
- });
- return { status: "started" };
- }
-
- async processMessage(
- tabId: string,
- message: string,
- keyId?: string,
- modelId?: string,
- reasoningEffort?: ReasoningEffort,
- workingDirectory?: string,
- agentModels?: AgentModelEntry[],
- content?: UserContentPart[],
- ): Promise<void> {
- const tabAgent = this._getOrCreateTabAgent(tabId);
-
- // Apply working directory override from frontend if provided
- if (workingDirectory !== undefined) {
- const prevDir = tabAgent.workingDirectoryOverride;
- tabAgent.workingDirectoryOverride = workingDirectory || undefined;
- // Invalidate cached agent if working directory changed
- if (prevDir !== tabAgent.workingDirectoryOverride) {
- tabAgent.agent = null;
- }
- }
- tabAgent.abortController = new AbortController();
- tabAgent.status = "running";
- this.messageCount += 1;
-
- // Persist the user message as a chunk row (once, before any fallback
- // retry). The whole turn — this user message plus the assistant's
- // chunk rows — shares one `turn_id`.
- const turnId = crypto.randomUUID();
- tabAgent.currentTurnId = turnId;
- // Announce the turn so the frontend can tag its live chunks with this
- // turn_id (stable render keys → flicker-free reconcile when the turn
- // seals). Emitted before any content delta.
- this.emit({ type: "turn-start", turnId }, tabId);
- appendChunks(tabId, explodeUserText(turnId, message));
-
- // Store agent models on the tab if provided (defines fallback order)
- if (agentModels) {
- tabAgent.agentModels = agentModels;
- }
-
- // Build the fallback sequence: the agent's models list in order, or a single manual entry
- const fallbackSequence = this.buildFallbackSequence(tabAgent, keyId, modelId);
- const maxFallbackAttempts = fallbackSequence.length;
-
- let processError: string | null = null;
- let allOutput = "";
- let currentKeyId: string | undefined;
- let currentModelId: string | undefined;
-
- for (let fallbackIdx = 0; fallbackIdx < maxFallbackAttempts; fallbackIdx++) {
- const entry = fallbackSequence[fallbackIdx];
- if (!entry) break; // unreachable: loop bound guarantees defined, satisfies TS
- // Convert empty strings (used when caller omitted keyId/modelId in
- // manual mode) to undefined so `getOrCreateAgentForTab` falls back
- // to the tabAgent's stored defaults via the `?? tabAgent.keyId` chain.
- currentKeyId = entry.key_id || undefined;
- currentModelId = entry.model_id || undefined;
- // Effort precedence: per-model (agent definition) → per-tab selector
- // (the `reasoningEffort` arg) → the Agent's own DEFAULT_REASONING_EFFORT
- // floor (applied inside `agent.run`).
- const effortForEntry = entry.effort ?? reasoningEffort;
- allOutput = "";
-
- // Single ordered chunk list accumulating this attempt's assistant
- // turn (text / thinking / tool-batch / error / system), folded from
- // the stream via the shared `appendEventToChunks` helper.
- const chunks: Chunk[] = [];
- // Per-attempt usage accumulator. Reset each fallback attempt so a
- // superseded (rate-limited) attempt's usage is discarded alongside its
- // `chunks`. One `usage` event → one UsageData row.
- const usageRows: UsageData[] = [];
- const assistantId = crypto.randomUUID();
- let assistantPersisted = false;
- tabAgent.currentChunks = chunks;
- tabAgent.currentAssistantId = assistantId;
-
- // Write-on-seal: explode the accumulated turn into flat chunk rows
- // ONCE, when the turn settles. `explodeTurn` splits each step's
- // `tool-batch` into separate `tool_call` + `tool_result` rows and
- // tags every row with `turn_id` + derived `step`.
- const flushAssistant = (): void => {
- if (assistantPersisted) return;
- // Append usage as extra drafts in the SAME appendChunks call as the
- // turn's content rows: one atomic write, one fsync, contiguous seqs.
- // Usage rows are an invisible side channel (excluded from
- // getChunksForTab); `step` is cosmetic for usage (never grouped).
- const drafts = explodeTurn(turnId, chunks);
- for (const u of usageRows) {
- drafts.push({ turnId, step: 0, role: "assistant", type: "usage", data: u });
- }
- if (drafts.length === 0) return;
- appendChunks(tabId, drafts);
- assistantPersisted = true;
- };
-
- let attemptError: string | null = null;
-
- try {
- const agent = await this.getOrCreateAgentForTab(tabId, currentKeyId, currentModelId);
-
- // Ensure tab exists in DB (frontend may have failed to create it)
- try {
- const { getDatabase } = await import("@dispatch/core");
- const db = getDatabase();
- const exists = db.query("SELECT 1 FROM tabs WHERE id = $id").get({ $id: tabId });
- if (!exists) {
- const { createTab } = await import("@dispatch/core");
- createTab(tabId, "New Tab", {
- keyId: currentKeyId ?? null,
- modelId: currentModelId ?? null,
- });
- }
- } catch {
- // Best-effort — if this fails, chunk persistence will throw and we'll catch it below
- }
-
- for await (const event of agent.run(message, {
- ...(effortForEntry ? { reasoningEffort: effortForEntry } : {}),
- abortSignal: tabAgent.abortController?.signal,
- ...(content ? { content } : {}),
- })) {
- // Stop processing if the tab was aborted (closed/stopped).
- // stopTab() already injected a `cancelled` system chunk into
- // `chunks` before flipping the abort flag, so we just need
- // to flush and exit.
- if (tabAgent.abortController?.signal.aborted) break;
-
- if (event.type === "error") {
- attemptError = event.error;
- // Record the error as a chunk so it's part of the
- // persisted turn history.
- appendEventToChunks(chunks, event);
- break;
- }
-
- if (event.type === "status") {
- tabAgent.status = event.status;
- }
- this.emit(event, tabId);
-
- // For diagnostics / child agent result harvesting, keep a
- // flat string copy of plain text output.
- if (event.type === "text-delta") {
- allOutput += event.delta;
- }
-
- // Capture per-step usage as a side-channel row to persist with the
- // turn (one row per `usage` event). The live `this.emit(event)`
- // above still drives in-session accumulation; this is the reload-
- // persistence path. `appendEventToChunks` intentionally ignores
- // `usage`, so it never becomes message content.
- if (event.type === "usage") {
- usageRows.push({ ...event.usage });
- }
-
- // Route every content-bearing event through the shared helper.
- // `appendEventToChunks` ignores lifecycle events (status / done
- // / task-list-update / tab-created / message-* / etc), so it's
- // safe to call unconditionally. Persistence happens once, after
- // the loop, so we never write a partial turn that a fallback
- // retry would then duplicate.
- appendEventToChunks(chunks, event);
- }
- } catch (err) {
- console.error(`[dispatch] processMessage error for tab ${tabId}:`, err);
- attemptError = err instanceof Error ? err.message : String(err);
- }
-
- // Decide whether a fallback retry will supersede this attempt.
- const isRetryable =
- attemptError !== null &&
- (attemptError.includes("status=429") ||
- attemptError.toLowerCase().includes("rate limit") ||
- attemptError.toLowerCase().includes("rate_limit") ||
- attemptError.toLowerCase().includes("usage limit") ||
- attemptError.toLowerCase().includes("exhausted"));
- const nextEntry = fallbackSequence[fallbackIdx + 1];
- const willRetry = Boolean(isRetryable && this.modelRegistry && tabAgent.keyId && nextEntry);
-
- // Persist this attempt's turn — unless a retry will replace it, in
- // which case the partial (and its error chunk) is discarded so the
- // next attempt's chunks don't merge with a failed one. On success,
- // abort, or a final error, the turn is flushed exactly once.
- if (!willRetry) {
- flushAssistant();
- }
- tabAgent.currentChunks = null;
- tabAgent.currentAssistantId = null;
-
- // No error — success
- if (!attemptError) {
- processError = null;
- break;
- }
-
- if (willRetry && nextEntry && tabAgent.keyId) {
- this.modelRegistry?.markKeyExhausted(tabAgent.keyId, attemptError);
- const fallbackMsg =
- `Key "${tabAgent.keyId}" rate limited. ` +
- `Falling back to "${nextEntry.key_id}" (model: ${nextEntry.model_id})...`;
- console.warn(`[dispatch] ${fallbackMsg}`);
- // Persist the notice + model-change as standalone system chunk
- // rows (no turn in flight now — currentChunks was just cleared).
- this.emit({ type: "notice", message: fallbackMsg }, tabId);
- this.routeSystemEventToTab(tabId, "notice", fallbackMsg);
- this.emit(
- { type: "model-changed", keyId: nextEntry.key_id, modelId: nextEntry.model_id },
- tabId,
- );
- this.routeSystemEventToTab(
- tabId,
- "model-changed",
- `Switched to ${nextEntry.model_id} (${nextEntry.key_id})`,
- );
- tabAgent.agent = null;
- continue;
- }
-
- // All fallbacks exhausted or non-retryable error
- processError = attemptError;
- tabAgent.status = "error";
- this.emit({ type: "error", error: attemptError }, tabId);
- this.emit({ type: "status", status: "error" }, tabId);
- break;
- }
- // Turn fully settled and its chunks are now persisted (flushAssistant ran
- // above). Signal the frontend that the turn's rows — with real seqs — are
- // durable so it can fold its live representation into the sealed log.
- // Emitted AFTER status:idle/error (which fire before the DB write).
- // Carry the authoritative usage aggregate (read AFTER the usage rows were
- // persisted) so the frontend reconciles its live cacheStats to the DB truth
- // — self-healing the live overshoot from a discarded rate-limited attempt.
- let usageStats: UsageStats | null = null;
- try {
- usageStats = getUsageStatsForTab(tabId);
- } catch {
- // DB read failed — omit reconciliation rather than crash the turn.
- }
- this.emit({ type: "turn-sealed", turnId, usageStats }, tabId);
-
- // Turn fully settled — clear the shared turn id.
- tabAgent.currentTurnId = null;
-
- // Resolve completion promise for child agents
- if (processError === null) {
- tabAgent.finalOutput = allOutput;
- tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" });
- } else {
- tabAgent.completionResolve?.({ status: "error", error: processError });
- }
-
- // The turn has fully settled. If messages piled up on the queue during it
- // and were NOT injected as a mid-turn interrupt (they arrived after the
- // last tool call, or this turn had no tool calls), kick off a fresh turn
- // to answer them instead of letting them sit unanswered — the queue is
- // consumed, not just appended. Only on a clean finish: a turn the user
- // explicitly stopped, or one that errored out, leaves its queue intact
- // for the next deliberate send (see continueFromQueue).
- if (processError === null) {
- this.continueFromQueue(tabId);
- }
- }
-
- /**
- * Start a new turn for any messages that accumulated on `tabId`'s queue
- * during the turn that just finished. This is what makes a queued message
- * (from a user OR another agent via send_to_tab) actually get a response
- * after the agent's current turn ends, rather than waiting forever.
- *
- * Loop safety: a queued-then-continued turn draws from the SAME
- * `autoWakeBudget` that bounds agent-to-agent wakes. Every human-originated
- * message refills that budget when it is delivered (see deliverMessage), so
- * human conversations are never throttled; only a runaway agent<->agent
- * chain (A queues B, B queues A, ...) is capped. When the budget is spent
- * the messages stay queued and a notice is emitted; the next human message
- * refills the budget and starts their turn.
- */
- private continueFromQueue(tabId: string): void {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return;
- if (tabAgent.messageQueue.length === 0) return;
- // Never auto-continue a turn the user stopped or one that errored.
- if (tabAgent.status === "error") return;
- if (tabAgent.abortController?.signal.aborted) return;
-
- if (tabAgent.autoWakeBudget <= 0) {
- // Budget spent — hold the queued messages (don't drop them) until a
- // human message refills the budget. Prevents unbounded agent loops.
- const notice =
- `Automatic continuation limit reached for this tab ` +
- `(${MAX_AGENT_AUTO_WAKES} consecutive turns). Queued messages are held ` +
- `until you send a message here.`;
- this.emit({ type: "notice", message: notice }, tabId);
- this.routeSystemEventToTab(tabId, "notice", notice);
- return;
- }
- tabAgent.autoWakeBudget -= 1;
-
- // Drain the queue as a "continuation" so the frontend folds the pending
- // queued bubbles into this NEW turn's initiating user row (rather than
- // into a running turn's tool result, which is the "interrupt" case).
- const drained = this.dequeueMessages(tabId, "continuation");
- if (drained.length === 0) return;
- const message = drained.map((m) => m.message).join("\n---\n");
-
- // Reuse the tab's resolved key/model/fallback chain — the continuation is
- // the same conversation, just a new turn. Fire-and-forget: if more
- // messages arrive during it, its own tail will continue the chain.
- this.processMessage(
- tabId,
- message,
- tabAgent.keyId ?? undefined,
- tabAgent.modelId ?? undefined,
- undefined,
- undefined,
- tabAgent.agentModels,
- ).catch((err) => {
- console.error(`[dispatch] continueFromQueue processMessage error for tab ${tabId}:`, err);
- });
- }
-
- private buildFallbackSequence(
- tabAgent: TabAgent,
- keyId?: string,
- modelId?: string,
- ): AgentModelEntry[] {
- // Agent mode: use the agent's configured fallback hierarchy in strict order
- const models = tabAgent.agentModels;
- if (models && models.length > 0) {
- const startIdx = models.findIndex((m) => m.key_id === keyId && m.model_id === modelId);
- return startIdx >= 0 ? models.slice(startIdx) : models;
- }
- // Manual mode: no fallback — just the selected key/model pair.
- // Always return at least one entry so `processMessage` runs the agent
- // once (empty strings let `getOrCreateAgentForTab` fall back to the
- // tabAgent's stored defaults or environment-driven config).
- return [{ key_id: keyId ?? "", model_id: modelId ?? "" }];
- }
-
- queueMessage(tabId: string, message: string, clientId?: string): { messageId: string } {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) throw new Error("Tab not found");
- const id = clientId || crypto.randomUUID();
- const queued: QueuedMessage = { id, message, timestamp: Date.now() };
- tabAgent.messageQueue.push(queued);
- // Wake up any blocking tools waiting for queue
- for (const listener of tabAgent.queueListeners) {
- listener();
- }
- tabAgent.queueListeners = [];
- this.emit({ type: "message-queued", tabId, messageId: id, message }, tabId);
- return { messageId: id };
- }
-
- cancelQueuedMessage(tabId: string, messageId: string): boolean {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return false;
- const idx = tabAgent.messageQueue.findIndex((m) => m.id === messageId);
- if (idx === -1) return false;
- tabAgent.messageQueue.splice(idx, 1);
- this.emit({ type: "message-cancelled", tabId, messageId }, tabId);
- return true;
- }
-
- dequeueMessages(
- tabId: string,
- reason: "interrupt" | "continuation" = "interrupt",
- ): QueuedMessage[] {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return [];
- const messages = [...tabAgent.messageQueue];
- tabAgent.messageQueue = [];
- if (messages.length > 0) {
- this.emit(
- { type: "message-consumed", tabId, messageIds: messages.map((m) => m.id), reason },
- tabId,
- );
- }
- return messages;
- }
-
- waitForQueuedMessage(tabId: string): { promise: Promise<void>; cancel: () => void } {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return { promise: Promise.resolve(), cancel: () => {} };
- if (tabAgent.messageQueue.length > 0) return { promise: Promise.resolve(), cancel: () => {} };
-
- let listener: (() => void) | null = null;
- const promise = new Promise<void>((resolve) => {
- listener = resolve;
- tabAgent.queueListeners.push(resolve);
- });
- const cancel = () => {
- if (listener) {
- tabAgent.queueListeners = tabAgent.queueListeners.filter((l) => l !== listener);
- listener = null;
- }
- };
- return { promise, cancel };
- }
-
- destroy(): void {
- this.configWatcher?.close();
- this.skillsWatcher?.close();
- for (const watcher of this.lspDirWatchers.values()) watcher.close();
- this.lspDirWatchers.clear();
- // Shut down all long-lived LSP server processes. Fire-and-forget: the
- // promise is detached so `destroy()` stays synchronous (matching its
- // existing contract), but every client gets `shutdown()` called.
- void this.lspManager.shutdownAll();
- }
-}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
deleted file mode 100644
index 72188ff..0000000
--- a/packages/api/src/app.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-import {
- type AgentModelEntry,
- getTab,
- isReasoningEffort,
- NotificationDispatcher,
- type UserContentPart,
- validateUserContent,
-} from "@dispatch/core";
-import { Hono } from "hono";
-import { cors } from "hono/cors";
-import { AgentManager } from "./agent-manager.js";
-import { PermissionManager } from "./permission-manager.js";
-import { agentsRoutes } from "./routes/agents.js";
-import { configRoutes } from "./routes/config.js";
-import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
-import { notificationsRoutes } from "./routes/notifications.js";
-import { skillsRoutes } from "./routes/skills.js";
-import { tabsRoutes } from "./routes/tabs.js";
-
-/**
- * Validate and normalise the `agentModels` fallback chain coming from the
- * frontend. Each entry must carry string `key_id`/`model_id`; an `effort` is
- * kept only when it's a recognised level (otherwise dropped so the per-tab /
- * default effort applies). Returns `undefined` when the input isn't an array.
- */
-function sanitizeAgentModels(raw: unknown): AgentModelEntry[] | undefined {
- if (!Array.isArray(raw)) return undefined;
- const out: AgentModelEntry[] = [];
- for (const m of raw) {
- if (!m || typeof m !== "object") continue;
- const entry = m as Record<string, unknown>;
- if (typeof entry.key_id !== "string" || typeof entry.model_id !== "string") continue;
- out.push({
- key_id: entry.key_id,
- model_id: entry.model_id,
- ...(isReasoningEffort(entry.effort) ? { effort: entry.effort } : {}),
- });
- }
- return out;
-}
-
-/**
- * Validate and normalise the optional multimodal `content` array from the
- * `/chat` body. Each entry is either a `{ type: "text", text }` part or a
- * `{ type: "attachment", mediaType, data, name? }` part (base64 payload).
- * Returns `undefined` when the input isn't a non-empty array or contains no
- * attachment (so the plain-string path is taken — byte-identical to before).
- * Shape only: SIZE/TYPE limits are enforced separately by `validateUserContent`.
- */
-function sanitizeUserContent(raw: unknown): UserContentPart[] | undefined {
- if (!Array.isArray(raw) || raw.length === 0) return undefined;
- const out: UserContentPart[] = [];
- let hasAttachment = false;
- for (const p of raw) {
- if (!p || typeof p !== "object") continue;
- const part = p as Record<string, unknown>;
- if (part.type === "text") {
- if (typeof part.text === "string") out.push({ type: "text", text: part.text });
- continue;
- }
- if (part.type === "attachment") {
- if (typeof part.mediaType !== "string" || typeof part.data !== "string") continue;
- hasAttachment = true;
- out.push({
- type: "attachment",
- mediaType: part.mediaType,
- data: part.data,
- ...(typeof part.name === "string" ? { name: part.name } : {}),
- });
- }
- }
- // No attachment → let the plain-text path handle it (avoids needlessly
- // switching the model message to array content for a text-only turn).
- return hasAttachment ? out : undefined;
-}
-
-export const permissionManager = new PermissionManager();
-export const agentManager = new AgentManager(permissionManager);
-
-// ntfy.sh push notifications. The dispatcher reads its config from the
-// `settings` table on every send, so config changes apply immediately —
-// no restart, no re-attach needed.
-export const notificationDispatcher = new NotificationDispatcher({
- getTabTitle: (tabId) => {
- try {
- return getTab(tabId)?.title ?? null;
- } catch {
- return null;
- }
- },
- getTabParentId: (tabId) => {
- try {
- // `undefined` when the lookup fails (tab not found / DB unavailable)
- // so the dispatcher falls back to "treat as top-level" rather than
- // silently dropping notifications.
- const row = getTab(tabId);
- return row ? row.parentTabId : undefined;
- } catch {
- return undefined;
- }
- },
-});
-notificationDispatcher.attachToAgentManager(agentManager);
-notificationDispatcher.attachToPermissionManager(permissionManager);
-
-export const app = new Hono();
-
-app.use(
- "*",
- cors({
- origin: (origin) => origin || "*",
- credentials: true,
- allowHeaders: ["Content-Type", "Authorization"],
- allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
- }),
-);
-
-app.get("/health", (c) => {
- return c.json({ ok: true });
-});
-
-app.get("/status", (c) => {
- return c.json({
- status: agentManager.getStatus(),
- messageCount: agentManager.getMessageCount(),
- statuses: agentManager.getAllStatuses(),
- });
-});
-
-app.post("/chat", async (c) => {
- const body = await c.req.json<{
- tabId?: unknown;
- message?: unknown;
- content?: unknown;
- keyId?: unknown;
- modelId?: unknown;
- agentModels?: unknown;
- reasoningEffort?: unknown;
- workingDirectory?: unknown;
- queueId?: unknown;
- }>();
- const { tabId, message } = body;
-
- if (typeof tabId !== "string" || tabId.trim() === "") {
- return c.json({ error: "tabId must be a non-empty string" }, 400);
- }
-
- if (typeof message !== "string" || message.trim() === "") {
- return c.json({ error: "message must be a non-empty string" }, 400);
- }
-
- const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
- const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
- const agentModels = sanitizeAgentModels(body.agentModels);
- const workingDirectory =
- typeof body.workingDirectory === "string" ? body.workingDirectory : undefined;
- const queueId = typeof body.queueId === "string" ? body.queueId : undefined;
- const reasoningEffort = isReasoningEffort(body.reasoningEffort)
- ? body.reasoningEffort
- : undefined;
-
- // Optional multimodal content (image/pdf attachments). When present, the
- // attachments are EPHEMERAL — forwarded to the model for this turn only and
- // never persisted (the chunk log keeps just `message`, which the frontend
- // has already projected to text with `[image]`/`[pdf]` markers).
- const content = sanitizeUserContent(body.content);
- if (content) {
- // Enforce size/type/count ceilings server-side (defence in depth; the
- // frontend also enforces them at paste time). Reject the whole request
- // so no tokens are spent on an over-limit payload.
- const validation = validateUserContent(content);
- if (!validation.ok) {
- return c.json({ error: "invalid attachments", details: validation.errors }, 400);
- }
- // Attachments only attach to a FRESH turn. If the tab is mid-turn the
- // message would queue (text-only machinery), silently dropping the
- // images. Reject clearly instead so the user can retry once idle.
- if (agentManager.getTabStatus(tabId) === "running") {
- return c.json(
- { error: "cannot attach images while the agent is generating; wait for it to finish" },
- 409,
- );
- }
- }
-
- // Single routing decision (queue if busy, new turn if idle) shared with the
- // `send_to_tab` tool via `AgentManager.deliverMessage`. Non-blocking — a
- // started turn runs in the background.
- const outcome = agentManager.deliverMessage(tabId, message, {
- ...(keyId ? { keyId } : {}),
- ...(modelId ? { modelId } : {}),
- ...(agentModels ? { agentModels } : {}),
- ...(reasoningEffort ? { reasoningEffort } : {}),
- ...(workingDirectory !== undefined ? { workingDirectory } : {}),
- ...(queueId ? { queueId } : {}),
- ...(content ? { content } : {}),
- });
-
- if (outcome.status === "queued") {
- return c.json({ status: "queued", messageId: outcome.messageId });
- }
- return c.json({ status: "ok" });
-});
-
-app.route("/config", configRoutes);
-
-app.post("/chat/cancel", async (c) => {
- const body = await c.req.json();
- if (typeof body.tabId !== "string" || typeof body.messageId !== "string") {
- return c.json({ error: "tabId and messageId are required strings" }, 400);
- }
- const tabId = body.tabId;
- const messageId = body.messageId;
- const cancelled = agentManager.cancelQueuedMessage(tabId, messageId);
- return c.json({ success: cancelled });
-});
-
-app.post("/chat/stop", async (c) => {
- const body = await c.req.json();
- if (typeof body.tabId !== "string") {
- return c.json({ error: "tabId is required" }, 400);
- }
- agentManager.stopTab(body.tabId);
- return c.json({ success: true });
-});
-
-// Prompt-cache WARMING (see AgentManager.warmCacheForTab / Agent.warmCache).
-//
-// Replays the tab's exact cached prefix + one trivial throwaway turn so the
-// provider's ~5-min prompt-cache TTL is refreshed while the tab sits idle.
-// The frontend's cache-warming timer drives this every ~4 minutes. The
-// warming request is NEVER persisted, NEVER emitted, and NEVER folded into the
-// real usage aggregate — we return ONLY its `usage` so the UI can show a
-// warming-specific "last request" cache rate without polluting the real
-// Cache Rate metric. Returns 409 when the tab is mid-turn (caller also gates).
-app.post("/chat/warm", async (c) => {
- const body = await c.req.json<{
- tabId?: unknown;
- keyId?: unknown;
- modelId?: unknown;
- agentModels?: unknown;
- reasoningEffort?: unknown;
- }>();
- const { tabId } = body;
- if (typeof tabId !== "string" || tabId.trim() === "") {
- return c.json({ error: "tabId must be a non-empty string" }, 400);
- }
- const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
- const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
- const agentModels = sanitizeAgentModels(body.agentModels);
- // Same effort the real turn would use — a message-cache key, so warming must
- // match it to refresh the SAME bucket the next real message reads.
- const reasoningEffort = isReasoningEffort(body.reasoningEffort)
- ? body.reasoningEffort
- : undefined;
-
- const result = await agentManager.warmCacheForTab(tabId, {
- ...(keyId ? { keyId } : {}),
- ...(modelId ? { modelId } : {}),
- ...(agentModels ? { agentModels } : {}),
- ...(reasoningEffort ? { reasoningEffort } : {}),
- });
- if (!result.ok) {
- // "tab is generating" is an expected race (not a server fault) → 409.
- const status = result.error === "tab is generating" ? 409 : 500;
- return c.json({ error: result.error }, status);
- }
- return c.json({ usage: result.usage });
-});
-
-app.route("/skills", skillsRoutes);
-app.route("/models", modelsRoutes);
-app.route("/tabs", tabsRoutes);
-app.route("/agents", agentsRoutes);
-app.route("/notifications", notificationsRoutes);
-
-// Start the wake scheduler on boot (restores persisted schedule)
-startWakeScheduler();
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
deleted file mode 100644
index 5615e08..0000000
--- a/packages/api/src/index.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import type { PermissionReply } from "@dispatch/core";
-import { createBunWebSocket } from "hono/bun";
-import { agentManager, app, permissionManager } from "./app.js";
-
-const { upgradeWebSocket, websocket } = createBunWebSocket();
-
-let clientIdCounter = 0;
-
-app.get(
- "/ws",
- upgradeWebSocket((_c) => {
- const clientId = String(++clientIdCounter);
-
- return {
- onOpen(_event, ws) {
- // Send current statuses immediately
- ws.send(JSON.stringify({ type: "statuses", statuses: agentManager.getAllStatuses() }));
-
- // Send any pending permission prompts
- const pending = permissionManager.getPending();
- if (pending.length > 0) {
- ws.send(JSON.stringify({ type: "permission-prompt", pending }));
- }
-
- const unsubscribe = agentManager.onEvent((event) => {
- ws.send(JSON.stringify(event));
- });
-
- permissionManager.registerClient(clientId, (data) => {
- ws.send(JSON.stringify(data));
- });
-
- // Store cleanup on the raw socket
- (ws as unknown as { _unsub?: () => void; _clientId?: string })._unsub = unsubscribe;
- (ws as unknown as { _unsub?: () => void; _clientId?: string })._clientId = clientId;
- },
- onMessage(event, _ws) {
- try {
- const message = JSON.parse(String(event.data)) as {
- type?: string;
- id?: string;
- reply?: string;
- };
- if (
- message.type === "permission-reply" &&
- typeof message.id === "string" &&
- typeof message.reply === "string"
- ) {
- const validReplies: PermissionReply[] = ["once", "always", "reject"];
- if (validReplies.includes(message.reply as PermissionReply)) {
- permissionManager.reply(message.id, message.reply as PermissionReply);
- }
- }
- } catch {
- // ignore malformed messages
- }
- },
- onClose(_event, ws) {
- const raw = ws as unknown as { _unsub?: () => void; _clientId?: string };
- if (raw._unsub) {
- raw._unsub();
- }
- if (raw._clientId) {
- permissionManager.unregisterClient(raw._clientId);
- }
- },
- };
- }),
-);
-
-export { app };
-
-// Starting port (overridable via PORT). When the port is already in use we
-// bump up one at a time (3000 → 3001 → 3002, …) until we find a free one, so
-// multiple dispatch instances (e.g. testing several features at once) can
-// coexist without manually juggling ports. The frontend defaults to :3000 —
-// point it at the chosen port via the in-app API-URL field / VITE_API_URL
-// when a bump happens.
-const START_PORT = Number(process.env.PORT) || 3000;
-
-/**
- * Bind the server to `START_PORT`, incrementing by one on EADDRINUSE until a
- * free port is found (up to the maximum valid TCP port, 65535). Bun's
- * `Bun.serve` throws synchronously when the port is taken, so we can catch and
- * retry. Returns the live server (whose `.port` reflects the port actually
- * bound).
- */
-function serveWithPortFallback() {
- let lastError: unknown;
- for (let port = START_PORT; port <= 65535; port++) {
- try {
- const server = Bun.serve({
- port,
- idleTimeout: 60,
- fetch: app.fetch,
- websocket,
- });
- if (port !== START_PORT) {
- console.warn(
- `dispatch: port ${START_PORT} in use — bound to ${port} instead. ` +
- `Set the frontend's API URL to http://localhost:${port}.`,
- );
- }
- console.log(`dispatch: API listening on http://localhost:${server.port}`);
- return server;
- } catch (err) {
- const code = (err as NodeJS.ErrnoException)?.code;
- if (code === "EADDRINUSE") {
- lastError = err;
- continue;
- }
- throw err;
- }
- }
- console.error(
- `dispatch: no free port at or above ${START_PORT}. ` +
- `Free one up or set PORT to an open port.`,
- );
- throw lastError ?? new Error(`No free port at or above ${START_PORT}`);
-}
-
-// Only start the server when run as the entry point — importing this module
-// (e.g. for `app`) must not bind a port. This preserves the prior
-// default-export behavior where Bun served only the entry file.
-if (import.meta.main) {
- serveWithPortFallback();
-}
diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts
deleted file mode 100644
index 3a24d03..0000000
--- a/packages/api/src/permission-manager.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import {
- type PermissionReply,
- type PermissionRequest,
- PermissionService,
- type Ruleset,
-} from "@dispatch/core";
-
-/**
- * Listener fired exactly once per newly-created pending prompt. Used by
- * the notification dispatcher so that a permission request triggers a
- * push notification on the user's phone (without re-firing every time
- * the pending list mutates for an unrelated reason).
- */
-export type PromptAddedListener = (prompt: {
- id: string;
- permission: string;
- description: string;
- metadata: Record<string, unknown>;
-}) => void;
-
-export class PermissionManager {
- private service = new PermissionService();
- private wsClients: Map<string, (data: unknown) => void> = new Map();
- private promptAddedListeners: Set<PromptAddedListener> = new Set();
- /** Ids that have already been broadcast as "added" — guards against re-emits. */
- private announcedPromptIds: Set<string> = new Set();
-
- registerClient(id: string, send: (data: unknown) => void): void {
- this.wsClients.set(id, send);
- }
-
- unregisterClient(id: string): void {
- this.wsClients.delete(id);
- }
-
- private broadcastPending(pending: Array<{ id: string; request: PermissionRequest }>): void {
- const message = {
- type: "permission-prompt",
- pending: pending.map((p) => ({ id: p.id, ...p.request })),
- };
- for (const send of this.wsClients.values()) {
- send(message);
- }
-
- // Detect newly-added prompts (ids present now that weren't before) and
- // fire `promptAddedListeners` once for each. Resolved/rejected ids are
- // pruned from `announcedPromptIds` so a future prompt that reuses an
- // id (theoretical, given the monotonic counter) would still notify.
- const currentIds = new Set(pending.map((p) => p.id));
- for (const id of this.announcedPromptIds) {
- if (!currentIds.has(id)) this.announcedPromptIds.delete(id);
- }
- for (const p of pending) {
- if (this.announcedPromptIds.has(p.id)) continue;
- this.announcedPromptIds.add(p.id);
- for (const listener of this.promptAddedListeners) {
- try {
- listener({
- id: p.id,
- permission: p.request.permission,
- description: p.request.description,
- metadata: p.request.metadata,
- });
- } catch (err) {
- console.warn(
- `[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
- }
- }
-
- async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise<PermissionReply> {
- const promise = this.service.ask(request, rulesets);
- this.broadcastPending(this.service.getPending());
- return promise;
- }
-
- reply(id: string, reply: PermissionReply): void {
- this.service.reply(id, reply);
- this.broadcastPending(this.service.getPending());
- }
-
- getPending(): Array<{ id: string; request: PermissionRequest }> {
- return this.service.getPending();
- }
-
- getService(): PermissionService {
- return this.service;
- }
-
- /**
- * Subscribe to "a new prompt is now pending" events. Fires once per
- * unique prompt id, even if `broadcastPending` is called repeatedly
- * for unrelated mutations. Returns an unsubscribe function.
- */
- onPromptAdded(listener: PromptAddedListener): () => void {
- this.promptAddedListeners.add(listener);
- return () => {
- this.promptAddedListeners.delete(listener);
- };
- }
-}
diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts
deleted file mode 100644
index 10ca714..0000000
--- a/packages/api/src/routes/agents.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import type { AgentDefinition } from "@dispatch/core";
-import {
- deleteAgent,
- getAgentDirs,
- isReasoningEffort,
- loadAgents,
- saveAgent,
-} from "@dispatch/core";
-import { Hono } from "hono";
-
-const SAFE_SLUG_RE = /^[a-zA-Z0-9_-]+$/;
-
-function isValidSlug(slug: string): boolean {
- return SAFE_SLUG_RE.test(slug) && slug.length > 0 && slug.length <= 100;
-}
-
-const agentsRoutes = new Hono();
-
-// GET /agents — list all agents (global + project-scoped)
-// Query param: ?projectDir=... (optional, the working directory)
-agentsRoutes.get("/", (c) => {
- const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined;
- const agents = loadAgents(projectDir);
- const dirs = getAgentDirs(projectDir);
- return c.json({ agents, dirs });
-});
-
-// GET /agents/dirs — list available agent directories
-agentsRoutes.get("/dirs", (c) => {
- const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined;
- const dirs = getAgentDirs(projectDir);
- return c.json({ dirs });
-});
-
-// POST /agents — create or update an agent
-agentsRoutes.post("/", async (c) => {
- try {
- const body = await c.req.json<AgentDefinition>();
- // Validate required fields
- if (!body.name || !body.slug || !body.scope) {
- return c.json({ error: "name, slug, and scope are required" }, 400);
- }
- if (!isValidSlug(body.slug)) {
- return c.json(
- { error: "Invalid slug: must be alphanumeric with hyphens/underscores only" },
- 400,
- );
- }
- if (body.scope !== "global" && body.scope.includes("..")) {
- return c.json({ error: "Invalid scope" }, 400);
- }
- // Ensure arrays exist
- const agent: AgentDefinition = {
- name: body.name,
- description: body.description || "",
- skills: body.skills || [],
- tools: body.tools || [],
- models: (body.models || []).map((m) => ({
- key_id: m.key_id,
- model_id: m.model_id,
- // Keep `effort` only when it's a recognised level; drop anything else.
- ...(isReasoningEffort(m.effort) ? { effort: m.effort } : {}),
- })),
- scope: body.scope,
- slug: body.slug,
- ...(body.cwd ? { cwd: body.cwd } : {}),
- ...(body.is_subagent ? { is_subagent: true } : {}),
- };
- saveAgent(agent);
- return c.json({ ok: true, agent });
- } catch (err) {
- return c.json({ error: err instanceof Error ? err.message : "Failed to save agent" }, 500);
- }
-});
-
-// DELETE /agents/:slug — delete an agent
-// Query param: ?scope=... (required: "global" or directory path)
-agentsRoutes.delete("/:slug", (c) => {
- const slug = c.req.param("slug");
- const scope = c.req.query("scope");
- if (!scope) {
- return c.json({ error: "scope query param is required" }, 400);
- }
- if (!isValidSlug(slug)) {
- return c.json({ error: "Invalid slug" }, 400);
- }
- if (slug === "default" && scope === "global") {
- return c.json({ error: "Cannot delete the default agent" }, 403);
- }
- if (scope !== "global" && scope.includes("..")) {
- return c.json({ error: "Invalid scope" }, 400);
- }
- const deleted = deleteAgent(slug, scope);
- if (!deleted) {
- return c.json({ error: "Agent not found" }, 404);
- }
- return c.json({ ok: true });
-});
-
-// GET /agents/check-dir?path=... — check if a directory exists
-agentsRoutes.get("/check-dir", (c) => {
- let dirPath = c.req.query("path");
- if (!dirPath) {
- return c.json({ exists: false });
- }
- // Expand ~ to home directory
- if (dirPath === "~" || dirPath.startsWith("~/")) {
- dirPath = path.join(os.homedir(), dirPath.slice(1));
- }
- // Resolve relative paths against the project root
- if (!path.isAbsolute(dirPath)) {
- const projectDir = process.env.DISPATCH_WORKING_DIR || process.cwd();
- dirPath = path.resolve(projectDir, dirPath);
- }
- try {
- const stat = fs.statSync(dirPath);
- return c.json({ exists: stat.isDirectory(), resolved: dirPath });
- } catch {
- return c.json({ exists: false, resolved: dirPath });
- }
-});
-
-export { agentsRoutes };
diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts
deleted file mode 100644
index 65a1e2a..0000000
--- a/packages/api/src/routes/config.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { DispatchConfig } from "@dispatch/core";
-import { Hono } from "hono";
-
-let getConfig: () => DispatchConfig = () => ({ permissions: {} });
-
-export function setConfigGetter(getter: () => DispatchConfig): void {
- getConfig = getter;
-}
-
-const configRoutes = new Hono();
-
-configRoutes.get("/", (c) => {
- const config = getConfig();
-
- // Strip env field values from keys for security
- const safeConfig: DispatchConfig = {
- ...config,
- keys: config.keys?.map((key) => ({
- ...key,
- env: "***",
- })),
- };
-
- return c.json({ config: safeConfig });
-});
-
-export { configRoutes };
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
deleted file mode 100644
index a1700b1..0000000
--- a/packages/api/src/routes/models.ts
+++ /dev/null
@@ -1,1073 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { readFileSync, writeFileSync } from "node:fs";
-import { homedir } from "node:os";
-import type { ModelRegistry } from "@dispatch/core";
-import {
- ANTHROPIC_MODELS_FALLBACK,
- buildWakeProbeBody,
- type ClaudeAccount,
- fetchAnthropicModels,
- fetchCopilotUsage,
- fetchGoogleUsage,
- fetchOpencodeUsage,
- getAccountUsage,
- getAnthropicHeaders,
- getClaudeAccountsFromDB,
- getDatabase,
- importCredentialsFromFile,
- listApiKeys,
- listStoredCredentials,
- refreshAccountCredentialsAsync,
- resolveApiKey,
- resolveContextLimit,
- resolveModelCapabilities,
- selectHaikuModel,
- setApiKey,
- validateAccountCredentials,
-} from "@dispatch/core";
-import { Hono } from "hono";
-import {
- CLAUDE_RESET_OFFSET_HOURS,
- isProbeSlotMinute,
- nextDailyAfter,
- PROBE_SLOT_MINUTES,
- type ProbeSlotMinute,
- recoverScheduleEntry,
-} from "../wake-scheduler.js";
-
-let getRegistry: () => ModelRegistry | null = () => null;
-let getAccounts: () => ClaudeAccount[] = () => [];
-
-export function setModelsGetter(registryGetter: () => ModelRegistry | null): void {
- getRegistry = registryGetter;
-}
-
-export function setAccountsGetter(getter: () => ClaudeAccount[]): void {
- getAccounts = getter;
-}
-
-/** Load Claude accounts from the database. */
-function resolveClaudeAccounts(): ClaudeAccount[] {
- return getClaudeAccountsFromDB();
-}
-
-export const modelsRoutes = new Hono();
-
-modelsRoutes.get("/", (c) => {
- const registry = getRegistry();
- if (!registry) {
- return c.json({ keys: [] });
- }
-
- const keyStates = registry.getKeys();
-
- const keys = keyStates.map((ks) => ({
- id: ks.definition.id,
- provider: ks.definition.provider,
- status: ks.status,
- lastError: ks.lastError ?? null,
- exhaustedAt: ks.exhaustedAt ?? null,
- }));
-
- return c.json({ keys });
-});
-
-// Fetch available models for a specific provider key.
-modelsRoutes.get("/available", async (c) => {
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "no registry configured" }, 500);
- }
-
- const keyId = c.req.query("keyId");
- if (!keyId) {
- return c.json({ error: "keyId query parameter is required" }, 400);
- }
-
- const keyStates = registry.getKeys();
- const key = keyStates.find((ks) => ks.definition.id === keyId);
- if (!key) {
- return c.json({ error: `key not found: ${keyId}` }, 404);
- }
-
- // Anthropic provider: validate credentials and fetch models dynamically
- if (key.definition.provider === "anthropic") {
- const credFile = key.definition.credentials_file;
- const accounts = resolveClaudeAccounts();
- const account =
- accounts.find((a) => a.id === keyId) ??
- (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]);
-
- if (!account) {
- return c.json({ error: "no Claude credentials found" }, 500);
- }
-
- const profile = await validateAccountCredentials(account);
- if (!profile) {
- return c.json(
- {
- error: "Claude credentials are invalid or expired",
- details: "Run `claude` to re-authenticate.",
- },
- 401,
- );
- }
-
- const creds = account.credentials;
- let models = await fetchAnthropicModels(creds.accessToken);
- if (models.length === 0) {
- models = ANTHROPIC_MODELS_FALLBACK;
- }
-
- return c.json({
- models,
- subscriptionType: account.credentials.subscriptionType,
- ...(profile.email ? { email: profile.email } : {}),
- });
- }
-
- const apiKeyValue = resolveApiKey(keyId, key.definition.env);
- if (!apiKeyValue) {
- return c.json({ error: `no API key found for ${keyId}` }, 500);
- }
-
- const baseUrl = key.definition.base_url.replace(/\/+$/, "");
- const url = `${baseUrl}/models`;
- const headers: Record<string, string> = {
- Authorization: `Bearer ${apiKeyValue}`,
- };
- if (key.definition.provider === "github-copilot") {
- headers["Copilot-Integration-Id"] = "vscode-chat";
- }
-
- let response: Response;
- try {
- response = await fetch(url, { headers });
- } catch (err) {
- return c.json({ error: "provider API call failed", details: String(err) }, 502);
- }
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- return c.json(
- { error: "provider API returned error", status: response.status, details: text },
- 502,
- );
- }
-
- let data: { data: { id: string }[] };
- try {
- data = await response.json();
- } catch (err) {
- return c.json({ error: "failed to parse provider response", details: String(err) }, 502);
- }
-
- const models = data.data.map((m) => m.id.replace(/^models\//, ""));
- return c.json({ models });
-});
-
-// Resolve a model's MAXIMUM context window (in tokens) from the models.dev
-// catalog. Returns `{ contextLimit: number | null }`; `null` means the model's
-// limit is unknown (unsupported provider, unknown model, or catalog offline),
-// which the frontend renders without a denominator/percentage.
-modelsRoutes.get("/context-limit", async (c) => {
- const provider = c.req.query("provider");
- const modelId = c.req.query("modelId");
- if (!provider || !modelId) {
- return c.json({ error: "provider and modelId query parameters are required" }, 400);
- }
-
- const contextLimit = await resolveContextLimit(provider, modelId);
- return c.json({ contextLimit });
-});
-
-// Resolve a model's image / PDF INPUT capabilities from the models.dev catalog.
-// Returns `{ capabilities: { image, pdf } | null }`. `null` means UNKNOWN — the
-// provider is unmapped, the model is absent, the catalog predates the
-// `modalities` field, or the catalog is offline. The frontend treats `null` as
-// "can't verify" (optimistic allow) and a definitive `{ image: false }` as a
-// hard block (no tokens spent).
-modelsRoutes.get("/capabilities", async (c) => {
- const provider = c.req.query("provider");
- const modelId = c.req.query("modelId");
- if (!provider || !modelId) {
- return c.json({ error: "provider and modelId query parameters are required" }, 400);
- }
-
- const capabilities = await resolveModelCapabilities(provider, modelId);
- return c.json({ capabilities });
-});
-
-// List available Claude accounts with validated credentials
-modelsRoutes.get("/claude-accounts", async (c) => {
- const candidates = resolveClaudeAccounts();
-
- // Validate each account's credentials; only include ones with a working token
- const validated: Array<{
- id: string;
- label: string;
- source: string;
- subscriptionType: string;
- expiresAt: number;
- email?: string;
- }> = [];
-
- for (const acct of candidates) {
- const profile = await validateAccountCredentials(acct);
- if (profile) {
- validated.push({
- id: acct.id,
- label: acct.label,
- source: acct.source,
- subscriptionType: acct.credentials.subscriptionType ?? "unknown",
- expiresAt: acct.credentials.expiresAt,
- ...(profile.email ? { email: profile.email } : {}),
- });
- }
- }
-
- return c.json({ accounts: validated });
-});
-
-// Get usage for a specific Claude account
-modelsRoutes.get("/claude-usage", async (c) => {
- const accountId = c.req.query("accountId");
- const accounts = getAccounts();
- const accountAccounts = resolveClaudeAccounts();
- const allAccounts = accounts.length > 0 ? accounts : accountAccounts;
-
- let account: ClaudeAccount | undefined;
- if (accountId) {
- account = allAccounts.find((a) => a.id === accountId);
- if (!account) {
- return c.json({ error: `account not found: ${accountId}` }, 404);
- }
- } else {
- account = allAccounts[0];
- }
-
- if (!account) {
- return c.json({ error: "no Claude accounts available" }, 404);
- }
-
- const report = await getAccountUsage(account);
- if (!report) {
- return c.json({ error: "failed to fetch usage data" }, 502);
- }
-
- return c.json(report);
-});
-
-// Get usage for a specific key by ID
-modelsRoutes.get("/key-usage", async (c) => {
- const keyId = c.req.query("keyId");
- if (!keyId) {
- return c.json({ error: "keyId query parameter is required" }, 400);
- }
-
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "registry not available" }, 502);
- }
-
- const keys = registry.getKeys();
- const key = keys.find((k) => k.definition.id === keyId);
- if (!key) {
- return c.json({ error: `key not found: ${keyId}` }, 404);
- }
-
- const provider = key.definition.provider;
-
- try {
- if (provider === "anthropic") {
- const allAccounts = resolveClaudeAccounts();
- const credFile = key.definition.credentials_file;
- // Match by key ID (DB accounts) or source file (file accounts)
- const accounts = allAccounts.filter(
- (a) => a.id === keyId || (credFile && a.source === credFile),
- );
- if (accounts.length === 0 && allAccounts[0]) {
- accounts.push(allAccounts[0]);
- }
- if (accounts.length === 0) {
- return c.json({ error: "no Claude accounts available" }, 502);
- }
- // Fetch usage for matched accounts
- const accountResults = await Promise.all(
- accounts.map(async (acct) => {
- const report = await getAccountUsage(acct);
- return {
- label: acct.label,
- source: acct.source,
- subscriptionType: acct.credentials.subscriptionType,
- fiveHour: report?.fiveHour,
- sevenDay: report?.sevenDay,
- error: report ? undefined : "failed to fetch",
- };
- }),
- );
- return c.json({
- provider: "anthropic",
- accounts: accountResults,
- // Legacy single-account fields (first account)
- fiveHour: accountResults[0]?.fiveHour,
- sevenDay: accountResults[0]?.sevenDay,
- });
- } else if (provider === "opencode-go") {
- // Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus
- // OPENCODE_WS1_ID / OPENCODE_WS2_ID (keyed by the key's numeric suffix).
- const report = await fetchOpencodeUsage(key.definition.id);
- if (report) {
- return c.json({
- provider: "opencode-go",
- fiveHour: report.fiveHour,
- weekly: report.weekly,
- monthly: report.monthly,
- });
- }
- // Fall back: show limits info with link to console
- return c.json({
- provider: "opencode-go",
- unavailable: true,
- consoleUrl: "https://opencode.ai/auth",
- limits: {
- fiveHour: "$12",
- weekly: "$30",
- monthly: "$60",
- },
- });
- } else if (provider === "github-copilot") {
- const token = resolveApiKey(keyId, key.definition.env);
- if (!token) {
- return c.json({ error: `no API key found for ${keyId}` }, 502);
- }
- const report = await fetchCopilotUsage(token, key.definition.base_url);
- if (!report) {
- return c.json({ error: "failed to fetch usage data" }, 502);
- }
- return c.json({
- provider: "github-copilot",
- tokensConsumed: report.tokensConsumed,
- tokensRemaining: report.tokensRemaining,
- percentUsed: report.percentUsed,
- resetAt: report.resetAt,
- plan: report.plan,
- });
- } else if (provider === "google") {
- const token = resolveApiKey(keyId, key.definition.env);
- if (!token) {
- return c.json({ error: `no API key found for ${keyId}. Set GOOGLE_API_KEY env var.` }, 502);
- }
- const report = await fetchGoogleUsage(token, key.definition.base_url);
- if (!report) {
- return c.json({ error: "failed to fetch Google usage data" }, 502);
- }
- return c.json({
- provider: "google",
- models: report.models,
- currentUsage: report.currentUsage,
- weeklyUsage: report.weeklyUsage,
- });
- } else {
- return c.json({ error: "usage tracking not supported for this provider" }, 400);
- }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- return c.json({ error: `failed to fetch usage: ${message}` }, 502);
- }
-});
-
-// ─── API key management ───────────────────────────────────────
-
-modelsRoutes.post("/set-api-key", async (c) => {
- const body = await c.req.json<{ keyId?: string; apiKey?: string }>();
- if (typeof body.keyId !== "string" || !body.keyId) {
- return c.json({ error: "keyId is required" }, 400);
- }
- if (typeof body.apiKey !== "string" || !body.apiKey) {
- return c.json({ error: "apiKey is required" }, 400);
- }
-
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "registry not available" }, 502);
- }
-
- const keys = registry.getKeys();
- const key = keys.find((k) => k.definition.id === body.keyId);
- if (!key) {
- return c.json({ error: `key not found: ${body.keyId}` }, 404);
- }
-
- setApiKey(body.keyId, key.definition.provider, body.apiKey);
- return c.json({ success: true, keyId: body.keyId });
-});
-
-modelsRoutes.get("/api-keys-status", (c) => {
- const stored = listApiKeys();
- return c.json({ keys: stored });
-});
-
-// ─── Credential import ────────────────────────────────────────
-
-modelsRoutes.post("/import-credentials", async (c) => {
- const body = await c.req.json<{ keyId?: string }>();
- const keyId = body.keyId;
- if (typeof keyId !== "string" || !keyId) {
- return c.json({ error: "keyId is required" }, 400);
- }
-
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "registry not available" }, 502);
- }
-
- const keys = registry.getKeys();
- const key = keys.find((k) => k.definition.id === keyId);
- if (!key) {
- return c.json({ error: `key not found: ${keyId}` }, 404);
- }
-
- if (key.definition.provider !== "anthropic") {
- return c.json({ error: "credential import is only supported for anthropic keys" }, 400);
- }
-
- const credFile = key.definition.credentials_file;
- if (!credFile) {
- return c.json({ error: "no credentials_file configured for this key" }, 400);
- }
-
- const result = importCredentialsFromFile(keyId, key.definition.provider, credFile);
- if (!result.success) {
- return c.json({ error: result.error ?? "import failed" }, 400);
- }
-
- return c.json({ success: true, keyId });
-});
-
-modelsRoutes.get("/credentials-status", (c) => {
- const stored = listStoredCredentials();
- const status = stored.map((cred) => ({
- keyId: cred.keyId,
- provider: cred.provider,
- subscriptionType: cred.subscriptionType,
- sourceFile: cred.sourceFile,
- importedAt: cred.importedAt,
- updatedAt: cred.updatedAt,
- expired: cred.expiresAt < Date.now(),
- }));
- return c.json({ credentials: status });
-});
-
-// ─── Add key to dispatch.toml ─────────────────────────────────
-
-const VALID_PROVIDERS = ["anthropic", "opencode-go", "google"] as const;
-type SupportedProvider = (typeof VALID_PROVIDERS)[number];
-
-const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = {
- anthropic: "https://api.anthropic.com/v1",
- "opencode-go": "https://opencode.ai/zen/go/v1",
- google: "https://generativelanguage.googleapis.com/v1beta/openai",
-};
-
-modelsRoutes.post("/add-key", async (c) => {
- const body = await c.req.json<{ id?: unknown; provider?: unknown }>();
-
- // Validate id
- if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) {
- return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400);
- }
- const id = body.id.trim();
-
- // Validate provider
- if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) {
- return c.json({ error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, 400);
- }
- const provider = body.provider as SupportedProvider;
- const base_url = PROVIDER_BASE_URLS[provider];
-
- // Read current dispatch.toml
- const tomlPath = `${process.cwd()}/dispatch.toml`;
- let tomlContent: string;
- try {
- tomlContent = readFileSync(tomlPath, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
- }
-
- // Check for duplicate key id
- const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m");
- if (idPattern.test(tomlContent)) {
- return c.json({ error: `key with id "${id}" already exists` }, 409);
- }
-
- // Build the new [[keys]] block
- let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`;
- if (provider === "anthropic") {
- const credPath = `${homedir()}/.claude/.credentials-${id}.json`;
- newBlock += `\ncredentials_file = "${credPath}"`;
- } else {
- const envVar =
- provider === "google"
- ? "GOOGLE_API_KEY"
- : `DISPATCH_${id.toUpperCase().replace(/-/g, "_")}_KEY`;
- newBlock += `\nenv = "${envVar}"`;
- }
- newBlock += "\n";
-
- // Insert before the # ─── Permissions section if it exists, otherwise at end
- const permissionsMarker = /\n# [─-]+ Permissions/;
- let newContent: string;
- const permMatch = permissionsMarker.exec(tomlContent);
- if (permMatch) {
- const insertAt = permMatch.index;
- newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt);
- } else {
- newContent = tomlContent + newBlock;
- }
-
- try {
- writeFileSync(tomlPath, newContent, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
- }
-
- const key: { id: string; provider: string; base_url: string; credentials_file?: string } = {
- id,
- provider,
- base_url,
- };
- if (provider === "anthropic") {
- key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`;
- }
-
- return c.json({ success: true, key });
-});
-
-// ─── Remove key from dispatch.toml ────────────────────────────
-
-modelsRoutes.post("/remove-key", async (c) => {
- const body = await c.req.json<{ id?: unknown }>();
-
- if (typeof body.id !== "string" || !body.id.trim()) {
- return c.json({ error: "id is required" }, 400);
- }
- const id = body.id.trim();
-
- const tomlPath = `${process.cwd()}/dispatch.toml`;
- let tomlContent: string;
- try {
- tomlContent = readFileSync(tomlPath, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
- }
-
- // Match the [[keys]] block containing this id and remove it.
- // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF.
- const blockPattern = new RegExp(
- `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`,
- "s",
- );
- const match = blockPattern.exec(tomlContent);
- if (!match) {
- return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404);
- }
-
- const newContent =
- tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length);
-
- try {
- writeFileSync(tomlPath, newContent, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
- }
-
- return c.json({ success: true });
-});
-
-// ─── Shared wake function ─────────────────────────────────────
-
-/** Max chars of upstream error body to keep in the surfaced message. */
-const MAX_ERROR_BODY_CHARS = 200;
-
-/**
- * Turn a non-OK probe response into a short, human-readable reason. Anthropic
- * returns a JSON error envelope (`{ error: { message } }`); fall back to a
- * truncated raw body, then to the bare status. Never throws.
- */
-async function describeFailedResponse(res: Response): Promise<string> {
- let detail = "";
- try {
- const text = await res.text();
- try {
- const parsed = JSON.parse(text) as { error?: { message?: unknown } };
- const message = parsed?.error?.message;
- detail = typeof message === "string" ? message : text;
- } catch {
- detail = text;
- }
- } catch {
- detail = "";
- }
- detail = detail.trim().slice(0, MAX_ERROR_BODY_CHARS);
- return detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`;
-}
-
-async function wakeAllClaudeAccounts(): Promise<
- Array<{ label: string; ok: boolean; error?: string }>
-> {
- // Only wake accounts referenced by configured anthropic keys
- const allAccounts = resolveClaudeAccounts();
- const registry = getRegistry();
- const configuredKeyIds = new Set<string>();
- if (registry) {
- for (const ks of registry.getKeys()) {
- if (ks.definition.provider === "anthropic") {
- configuredKeyIds.add(ks.definition.id);
- }
- }
- }
- const accounts =
- configuredKeyIds.size > 0 ? allAccounts.filter((a) => configuredKeyIds.has(a.id)) : allAccounts;
- if (accounts.length === 0) {
- return [{ label: "(none)", ok: false, error: "no Claude accounts available" }];
- }
-
- const results: Array<{ label: string; ok: boolean; error?: string }> = [];
-
- for (const acct of accounts) {
- try {
- const creds = await refreshAccountCredentialsAsync(acct);
- if (!creds) {
- results.push({ label: acct.label, ok: false, error: "token refresh failed" });
- continue;
- }
-
- // Resolve the probe model dynamically. A fixed model id (the old
- // `claude-3-5-haiku-20241022`) eventually stops being served and
- // the probe 404s, so pull the live list from `/v1/models` and pick
- // the current Haiku. Fall back to the well-known list if the live
- // fetch comes back empty (network blip, transient upstream error).
- let availableModels = await fetchAnthropicModels(creds.accessToken);
- if (availableModels.length === 0) {
- availableModels = ANTHROPIC_MODELS_FALLBACK;
- }
- const probeModel = selectHaikuModel(availableModels);
- if (!probeModel) {
- results.push({
- label: acct.label,
- ok: false,
- error: "no 'haiku' model available from /v1/models",
- });
- continue;
- }
-
- // Mirror a genuine Claude Code CLI request. These are OAuth
- // (Pro/Max) subscription accounts: Anthropic validates the
- // `system[]` array and rejects (401/403) any request whose system
- // block lacks the verbatim Claude Code identity string. A bare
- // `{ model, messages }` body — what this probe used to send —
- // always failed, which is why scheduled wakes silently died with a
- // blank "failed" status. `buildWakeProbeBody` produces the correct
- // shape (billing header + identity); the session/request-id headers
- // match what the real CLI stamps so the probe isn't flagged.
- const res = await fetch("https://api.anthropic.com/v1/messages", {
- method: "POST",
- headers: {
- ...getAnthropicHeaders(creds.accessToken),
- "content-type": "application/json",
- "X-Claude-Code-Session-Id": randomUUID(),
- "x-client-request-id": randomUUID(),
- },
- body: JSON.stringify(buildWakeProbeBody(probeModel)),
- });
-
- if (res.ok) {
- results.push({ label: acct.label, ok: true });
- } else {
- // Surface WHY it failed so the panel never shows a bare
- // "failed" again and breakage stays debuggable.
- results.push({
- label: acct.label,
- ok: false,
- error: await describeFailedResponse(res),
- });
- }
- } catch (err) {
- results.push({
- label: acct.label,
- ok: false,
- error: err instanceof Error ? err.message : String(err),
- });
- }
- }
-
- return results;
-}
-
-modelsRoutes.post("/wake", async (c) => {
- const results = await wakeAllClaudeAccounts();
- return c.json({ results });
-});
-
-// ─── Wake scheduler (runs on backend, survives frontend close) ─
-//
-// A "marked hour" expands to 4 probe slots inside that hour: :00, :15, :30,
-// :45. Each slot is its own (hour, slot_minute) row in `wake_schedule` with
-// its own `next_wake_at`. When multiple slots come due in the same tick we
-// coalesce into a single upstream wake — no point hitting Anthropic 4× in
-// the same 30-second window.
-
-/** Schedule: hour (0-23) → slot minute (0/15/30/45) → next fire ms. */
-type WakeSchedule = Record<number, Partial<Record<ProbeSlotMinute, number>>>;
-
-interface PendingRetry {
- /** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */
- retriesLeft: number;
- /** Absolute timestamp (ms) of the next retry attempt. */
- nextRetryAt: number;
- /** Why we entered retry mode — surfaced on /wake-schedule. */
- reason: string;
-}
-
-interface LastWake {
- firedAt: number;
- ok: boolean;
- results: Array<{ label: string; ok: boolean; error?: string }>;
-}
-
-const MAX_RETRIES = 6;
-const RETRY_INTERVAL_MS = 5 * 60 * 1000;
-const TICK_INTERVAL_MS = 30_000;
-
-function setSlot(schedule: WakeSchedule, hour: number, minute: ProbeSlotMinute, ts: number): void {
- const hourEntry = schedule[hour] ?? {};
- hourEntry[minute] = ts;
- schedule[hour] = hourEntry;
-}
-
-function deleteHour(schedule: WakeSchedule, hour: number): void {
- delete schedule[hour];
-}
-
-function countSlots(schedule: WakeSchedule): number {
- let n = 0;
- for (const slots of Object.values(schedule)) {
- n += Object.keys(slots).length;
- }
- return n;
-}
-
-function loadScheduleFromDB(): WakeSchedule {
- try {
- const db = getDatabase();
- const rows = db
- .query("SELECT hour, slot_minute, next_wake_at FROM wake_schedule")
- .all() as Array<{ hour: number; slot_minute: number; next_wake_at: number }>;
- const schedule: WakeSchedule = {};
- const now = Date.now();
- let needsPersist = false;
- let anyShouldFire = false;
- for (const row of rows) {
- if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it
- const recovered = recoverScheduleEntry(row.next_wake_at, now);
- setSlot(schedule, row.hour, row.slot_minute, recovered.nextWakeAt);
- if (recovered.nextWakeAt !== row.next_wake_at) needsPersist = true;
- if (recovered.shouldFireNow) anyShouldFire = true;
- }
- if (needsPersist) persistSchedule(schedule);
- if (anyShouldFire) needsBootFire = true;
- return schedule;
- } catch {
- return {};
- }
-}
-
-function persistSchedule(scheduleToSave?: WakeSchedule): void {
- try {
- const db = getDatabase();
- const data = scheduleToSave ?? wakeSchedule;
- const insert = db.query(
- "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)",
- );
- // One atomic transaction: DELETE + every INSERT either all commit or all
- // roll back. Without this, an INSERT failure (disk full, bad row, etc.)
- // would leave the table empty — silently wiping the user's schedule on
- // next boot since the DELETE has already committed.
- const writeAll = db.transaction(() => {
- db.run("DELETE FROM wake_schedule");
- for (const [hour, slots] of Object.entries(data)) {
- for (const [slotMinute, nextWakeAt] of Object.entries(slots)) {
- if (nextWakeAt === undefined) continue;
- insert.run({
- $hour: Number(hour),
- $slot: Number(slotMinute),
- $nextWakeAt: nextWakeAt,
- });
- }
- }
- });
- writeAll();
- } catch {
- // Ignore DB errors — schedule still lives in-memory for this process,
- // and the previously persisted snapshot stays intact thanks to the
- // transaction rollback above.
- }
-}
-
-/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */
-let needsBootFire = false;
-const wakeSchedule: WakeSchedule = loadScheduleFromDB();
-
-/**
- * A single shared retry slot. We deliberately do NOT queue one retry per
- * failed wake — multiple back-to-back failures (e.g. the network is down for
- * five minutes) used to spawn retries that all converged on the same instant
- * and hammered the upstream. One in-flight retry covers all accounts.
- */
-let pendingRetry: PendingRetry | null = null;
-let lastWake: LastWake | null = null;
-
-// HMR-safe: track the scheduler timer on globalThis so re-imports during dev
-// don't leave orphaned timers running.
-const timerKey = "_dispatchWakeTimer";
-(globalThis as Record<string, unknown>)[timerKey] ??= undefined;
-let isTickRunning = false;
-
-function recordWake(results: Array<{ label: string; ok: boolean; error?: string }>): boolean {
- const ok = results.length > 0 && results.every((r) => r.ok);
- lastWake = { firedAt: Date.now(), ok, results };
- return ok;
-}
-
-function scheduleRetry(reason: string): void {
- if (pendingRetry) {
- // Already retrying — reset the budget so the next failure window covers
- // the new incident too, but don't compound timers.
- pendingRetry.retriesLeft = MAX_RETRIES;
- pendingRetry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
- pendingRetry.reason = reason;
- return;
- }
- pendingRetry = {
- retriesLeft: MAX_RETRIES,
- nextRetryAt: Date.now() + RETRY_INTERVAL_MS,
- reason,
- };
-}
-
-async function fireWake(reason: string): Promise<void> {
- try {
- const results = await wakeAllClaudeAccounts();
- const ok = recordWake(results);
- if (!ok) scheduleRetry(reason);
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- lastWake = {
- firedAt: Date.now(),
- ok: false,
- results: [{ label: "(scheduler)", ok: false, error: message }],
- };
- scheduleRetry(reason);
- }
-}
-
-async function processPendingRetry(now: number): Promise<void> {
- // Capture into a local so TS narrowing survives across awaits, and so a
- // racing toggle that clears `pendingRetry` mid-flight can't NPE us.
- const retry = pendingRetry;
- if (!retry || retry.nextRetryAt > now) return;
- try {
- const results = await wakeAllClaudeAccounts();
- const ok = recordWake(results);
- if (ok || retry.retriesLeft <= 1) {
- pendingRetry = null;
- } else {
- retry.retriesLeft -= 1;
- retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
- }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- lastWake = {
- firedAt: Date.now(),
- ok: false,
- results: [{ label: "(retry)", ok: false, error: message }],
- };
- if (retry.retriesLeft <= 1) {
- pendingRetry = null;
- } else {
- retry.retriesLeft -= 1;
- retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
- }
- }
-}
-
-interface DueSlot {
- hour: number;
- minute: ProbeSlotMinute;
- ts: number;
-}
-
-/** Collect every slot whose next_wake_at is at or before `now`. */
-function collectDueSlots(now: number): DueSlot[] {
- const due: DueSlot[] = [];
- for (const [hourStr, slots] of Object.entries(wakeSchedule)) {
- const hour = Number(hourStr);
- for (const [slotStr, ts] of Object.entries(slots)) {
- if (ts === undefined) continue;
- const slotMinute = Number(slotStr);
- if (!isProbeSlotMinute(slotMinute)) continue;
- if (ts <= now) due.push({ hour, minute: slotMinute, ts });
- }
- }
- return due;
-}
-
-async function schedulerTick(): Promise<void> {
- // Prevent concurrent tick execution (e.g. toggle called mid-tick).
- if (isTickRunning) return;
- isTickRunning = true;
-
- try {
- const now = Date.now();
- const due = collectDueSlots(now);
-
- let firedThisTick = false;
- const bootFireRequested = needsBootFire;
- if (due.length > 0 || bootFireRequested) {
- needsBootFire = false;
- // Advance every due slot before firing — so a slow upstream call
- // can't cause us to re-fire the same slot on the next tick.
- for (const slot of due) {
- const next = nextDailyAfter(slot.ts, now);
- setSlot(wakeSchedule, slot.hour, slot.minute, next);
- }
- persistSchedule();
-
- const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`);
- const fromBoot = bootFireRequested ? " (boot recovery)" : "";
- const reason =
- reasonParts.length > 0
- ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}`
- : "boot recovery";
- firedThisTick = true;
- // COALESCED: one upstream call covers all slots due this tick.
- await fireWake(reason);
- }
-
- // Only attempt a retry on ticks that didn't *just* fire — otherwise we'd
- // race the retry against a fresh attempt within the same loop iteration.
- if (!firedThisTick) {
- await processPendingRetry(Date.now());
- }
-
- // Keep ticking while there's anything to monitor.
- if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) {
- (globalThis as Record<string, unknown>)[timerKey] = setTimeout(
- schedulerTick,
- TICK_INTERVAL_MS,
- );
- }
- } finally {
- isTickRunning = false;
- }
-}
-
-export function startWakeScheduler(): void {
- // Clear any previous timer (HMR-safe — works with Bun's Timer objects).
- const prev = (globalThis as Record<string, unknown>)[timerKey];
- if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>);
- // Fire-and-forget; the tick re-arms itself.
- void schedulerTick();
-}
-
-function scheduleSnapshot(): {
- schedule: WakeSchedule;
- resetOffsetHours: number;
- probeSlotMinutes: readonly number[];
- lastWake: LastWake | null;
- pendingRetry: PendingRetry | null;
-} {
- return {
- schedule: wakeSchedule,
- resetOffsetHours: CLAUDE_RESET_OFFSET_HOURS,
- probeSlotMinutes: PROBE_SLOT_MINUTES,
- lastWake,
- pendingRetry,
- };
-}
-
-modelsRoutes.post("/wake-schedule/toggle", async (c) => {
- const body = await c.req.json<{
- hour?: unknown;
- action?: unknown;
- timestamps?: unknown;
- }>();
- const hour = body.hour;
- if (typeof hour !== "number" || !Number.isFinite(hour) || hour < 0 || hour > 23) {
- return c.json({ error: "hour must be a number 0-23" }, 400);
- }
- if (!Number.isInteger(hour)) {
- return c.json({ error: "hour must be an integer 0-23" }, 400);
- }
-
- // The action is the CLIENT'S DECLARED INTENT. Previously the server
- // derived add-vs-remove from its own in-memory state, which meant a UI
- // that had become stale (e.g. due to a snapshot race) would have its
- // clicks silently inverted: user clicks to turn ON an hour the UI shows
- // as OFF, server sees it as already-ON, deletes it. Requiring an explicit
- // action makes the request idempotent and self-describing — a stale UI's
- // click is now either a redundant no-op (action matches server state) or
- // a recoverable replace (action="on" against an already-on hour just
- // refreshes its timestamps to the new values).
- const action = body.action;
- if (action !== "on" && action !== "off") {
- return c.json({ error: "action must be 'on' or 'off'" }, 400);
- }
-
- if (action === "off") {
- // Idempotent: removing an already-removed hour is a no-op success.
- if (wakeSchedule[hour] !== undefined) {
- deleteHour(wakeSchedule, hour);
- }
- } else {
- // action === "on" — require a `timestamps` object with one absolute
- // Unix ms per probe slot (0, 15, 30, 45). The client is the source
- // of truth for the *local* wall-clock intent of each probe.
- // Idempotent: turning ON an already-on hour replaces its timestamps
- // (so a UI recovering from a desync can re-assert the correct wall-
- // clock intent without first deleting).
- const timestamps = body.timestamps;
- if (timestamps === null || typeof timestamps !== "object") {
- return c.json(
- { error: "timestamps must be an object { '0': ms, '15': ms, '30': ms, '45': ms }" },
- 400,
- );
- }
- const parsed: Partial<Record<ProbeSlotMinute, number>> = {};
- for (const slot of PROBE_SLOT_MINUTES) {
- const raw = (timestamps as Record<string, unknown>)[String(slot)];
- // Accept any finite Unix-ms number. We deliberately do NOT reject
- // past timestamps: client-server clock skew + request latency mean
- // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot
- // can land "in the past" by the time the server validates it. The
- // scheduler tick handles past entries correctly via
- // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then
- // advances by 24h * N to the next future occurrence.
- if (typeof raw !== "number" || !Number.isFinite(raw)) {
- return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400);
- }
- parsed[slot] = raw;
- }
- wakeSchedule[hour] = parsed;
- }
-
- persistSchedule();
- startWakeScheduler();
-
- return c.json(scheduleSnapshot());
-});
-
-modelsRoutes.get("/wake-schedule", (c) => {
- return c.json(scheduleSnapshot());
-});
diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts
deleted file mode 100644
index 473e837..0000000
--- a/packages/api/src/routes/notifications.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-// `/notifications` — ntfy.sh config + test-send route.
-
-import {
- defaultNtfyConfig,
- loadNtfyConfig,
- type NotificationEventType,
- NTFY_EVENT_TYPES,
- type NtfyConfig,
- normalizeNtfyConfig,
- redactNtfyConfig,
- saveNtfyConfig,
- sendNtfy,
-} from "@dispatch/core";
-import { Hono } from "hono";
-
-export const notificationsRoutes = new Hono();
-
-notificationsRoutes.get("/", (c) => {
- const config = loadNtfyConfig();
- return c.json({
- config: redactNtfyConfig(config),
- eventTypes: NTFY_EVENT_TYPES,
- defaults: defaultNtfyConfig(),
- });
-});
-
-notificationsRoutes.put("/", async (c) => {
- const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>();
- const existing = loadNtfyConfig();
-
- // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep
- // the existing token (the GET response redacts it, so the frontend doesn't
- // have it to send back). Any other string ⇒ replace.
- let nextAuthToken = existing.authToken;
- if (typeof body.authToken === "string") nextAuthToken = body.authToken;
-
- const merged = normalizeNtfyConfig({
- enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled,
- topic: typeof body.topic === "string" ? body.topic : existing.topic,
- authToken: nextAuthToken,
- events: { ...existing.events, ...(body.events ?? {}) },
- notifySubagents:
- typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents,
- });
-
- // Only validation: if notifications are turned on, the topic must be
- // non-empty. Any other "is this a valid ntfy topic name?" check is
- // punted to the ntfy server itself — its rules vary and have changed
- // over time, and a syntactically-valid name still might be rejected
- // (e.g. reserved words), so a clear server error is more useful than
- // a client-side guess.
- if (merged.enabled && !merged.topic.trim()) {
- return c.json({ error: "Topic is required" }, 400);
- }
-
- saveNtfyConfig(merged);
- return c.json({ config: redactNtfyConfig(merged) });
-});
-
-notificationsRoutes.post("/test", async (c) => {
- const config = loadNtfyConfig();
- if (!config.enabled) {
- return c.json({ ok: false, error: "Notifications are disabled" }, 400);
- }
- if (!config.topic.trim()) {
- return c.json({ ok: false, error: "Topic is required" }, 400);
- }
-
- // Use a real event type so the per-event toggle is honored when wiring
- // is tested end-to-end; pick `turn-completed` since it's the most
- // common enabled-by-default event.
- const eventType: NotificationEventType = "turn-completed";
- if (!config.events[eventType]) {
- return c.json(
- { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` },
- 400,
- );
- }
-
- const result = await sendNtfy(config, {
- type: eventType,
- title: "Dispatch test notification",
- message: "If you can see this, ntfy.sh notifications are wired up correctly.",
- tags: ["bell"],
- });
- if (!result.ok) return c.json(result, 502);
- return c.json(result);
-});
diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts
deleted file mode 100644
index 7696b47..0000000
--- a/packages/api/src/routes/skills.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core";
-import { Hono } from "hono";
-
-let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({
- skills: [],
- mappings: [],
-});
-
-export function setSkillsGetter(
- getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] },
-): void {
- getSkills = getter;
-}
-
-export const skillsRoutes = new Hono();
-
-skillsRoutes.get("/", (c) => {
- const { skills, mappings } = getSkills();
- const skillSummaries = skills.map(({ name, description, tags, scope, directory }) => ({
- name,
- description,
- tags,
- scope,
- directory,
- }));
- return c.json({ skills: skillSummaries, mappings });
-});
-
-skillsRoutes.get("/:name", (c) => {
- const { name } = c.req.param();
- const scopeParam = c.req.query("scope") as SkillScope | undefined;
- const { skills } = getSkills();
-
- const matches = skills.filter((s) => s.name === name);
- if (matches.length === 0) {
- return c.json({ error: "Skill not found" }, 404);
- }
-
- if (scopeParam) {
- const scoped = matches.find((s) => s.scope === scopeParam);
- if (!scoped) {
- return c.json({ error: "Skill not found" }, 404);
- }
- return c.json(scoped);
- }
-
- return c.json(matches[0]);
-});
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts
deleted file mode 100644
index 2ae60ed..0000000
--- a/packages/api/src/routes/tabs.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import {
- archiveTab,
- createTab,
- deleteSetting,
- getChunksForTab,
- getSetting,
- getTab,
- getTotalChunkCount,
- getUsageStatsForTab,
- groupRowsToMessages,
- listOpenTabs,
- setSetting,
- updateTabModel,
- updateTabPositions,
- updateTabStatus,
- updateTabTitle,
-} from "@dispatch/core";
-import { Hono } from "hono";
-
-export const tabsRoutes = new Hono();
-
-let getAgentManager: () => {
- stopTab(id: string): void;
- deleteTab(id: string): void;
- compactTab(tempTabId: string, sourceTabId: string): Promise<void>;
-} | null = () => null;
-
-export function setTabsAgentManager(
- getter: () => {
- stopTab(id: string): void;
- deleteTab(id: string): void;
- compactTab(tempTabId: string, sourceTabId: string): Promise<void>;
- } | null,
-): void {
- getAgentManager = getter;
-}
-
-tabsRoutes.get("/", (c) => {
- // Enrich each tab with its persisted usage aggregate so the frontend can
- // seed `cacheStats` on reload without an extra round-trip. N small indexed
- // queries — fine for tab counts.
- const tabs = listOpenTabs().map((t) => ({ ...t, usageStats: getUsageStatsForTab(t.id) }));
- return c.json({ tabs });
-});
-
-tabsRoutes.post("/", async (c) => {
- const body = await c.req.json<{ id?: string; title?: string }>();
- const id = body.id ?? crypto.randomUUID();
- const title = body.title ?? "New Tab";
- const tab = createTab(id, title);
- return c.json(tab);
-});
-
-// Settings routes (must be before /:id to avoid conflict)
-tabsRoutes.get("/settings/title-model", (c) => {
- const keyId = getSetting("title_model_key_id");
- const modelId = getSetting("title_model_id");
- return c.json({ keyId, modelId });
-});
-
-tabsRoutes.put("/settings/title-model", async (c) => {
- const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>();
- if (body.keyId !== undefined) {
- if (body.keyId) setSetting("title_model_key_id", body.keyId);
- else deleteSetting("title_model_key_id");
- }
- if (body.modelId !== undefined) {
- if (body.modelId) setSetting("title_model_id", body.modelId);
- else deleteSetting("title_model_id");
- }
- return c.json({ success: true });
-});
-
-// Conversation-compaction model (key+model used to generate the summary).
-// Mirrors the title-model setting. When unset, compaction falls back to the
-// source tab's own key+model.
-tabsRoutes.get("/settings/compaction-model", (c) => {
- const keyId = getSetting("compaction_model_key_id");
- const modelId = getSetting("compaction_model_id");
- return c.json({ keyId, modelId });
-});
-
-tabsRoutes.put("/settings/compaction-model", async (c) => {
- const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>();
- if (body.keyId !== undefined) {
- if (body.keyId) setSetting("compaction_model_key_id", body.keyId);
- else deleteSetting("compaction_model_key_id");
- }
- if (body.modelId !== undefined) {
- if (body.modelId) setSetting("compaction_model_id", body.modelId);
- else deleteSetting("compaction_model_id");
- }
- return c.json({ success: true });
-});
-
-// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids;
-// each tab's `position` is rewritten to its index. Must be declared before the
-// `/:id` routes so "reorder" isn't captured as an id param.
-tabsRoutes.patch("/reorder", async (c) => {
- const body = await c.req.json<{ ids?: string[] }>();
- if (!Array.isArray(body.ids) || body.ids.some((id) => typeof id !== "string")) {
- return c.json({ error: "ids must be an array of strings" }, 400);
- }
- updateTabPositions(body.ids);
- return c.json({ success: true });
-});
-
-tabsRoutes.get("/:id", (c) => {
- const id = c.req.param("id");
- const tab = getTab(id);
- if (!tab) return c.json({ error: "tab not found" }, 404);
- return c.json(tab);
-});
-
-// Conversation history for a tab, paginated at CHUNK granularity. The flat
-// chunk log is windowed by `limit`/`before` (both chunk-`seq` cursors) so a
-// single huge turn never dumps in full, then grouped into render messages.
-// `before` is the oldest chunk seq the client already holds. This is what
-// powers per-chunk frontend pagination / memory control.
-tabsRoutes.get("/:id/messages", (c) => {
- const id = c.req.param("id");
- const limitRaw = c.req.query("limit");
- const beforeRaw = c.req.query("before");
- const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
- const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined;
- const options =
- limit !== undefined || before !== undefined
- ? {
- ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}),
- ...(before !== undefined && Number.isFinite(before) ? { before } : {}),
- }
- : undefined;
- const chunks = getChunksForTab(id, options);
- const messages = groupRowsToMessages(chunks);
- // `oldestSeq` is the chunk-seq cursor the client pages backward from; null
- // when the window is empty.
- const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null;
- const total = getTotalChunkCount(id);
- return c.json({ messages, total, oldestSeq });
-});
-
-// Raw chunk window for a tab — the chunk-native frontend's load/paginate
-// source. Same `limit`/`before` chunk-`seq` windowing as `/messages`, but
-// returns the flat `ChunkRow[]` WITHOUT server-side grouping (the frontend
-// groups for render and evicts/paginates on the flat list). Dedupe on the
-// client by `seq` when overlap-fetching.
-tabsRoutes.get("/:id/chunks", (c) => {
- const id = c.req.param("id");
- const limitRaw = c.req.query("limit");
- const beforeRaw = c.req.query("before");
- const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
- const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined;
- const options =
- limit !== undefined || before !== undefined
- ? {
- ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}),
- ...(before !== undefined && Number.isFinite(before) ? { before } : {}),
- }
- : undefined;
- const chunks = getChunksForTab(id, options);
- const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null;
- const total = getTotalChunkCount(id);
- return c.json({ chunks, total, oldestSeq });
-});
-
-// Trigger conversation compaction. The `:id` is the TRANSIENT placeholder tab
-// hosting the "compacting…" UI; `sourceTabId` (body) is the conversation being
-// compacted. Fire-and-forget on the server: progress/outcome is delivered via
-// the `compaction-*` WS events. Returns 202 once the run is kicked off.
-tabsRoutes.post("/:id/compact", async (c) => {
- const tempTabId = c.req.param("id");
- const body = await c.req
- .json<{ sourceTabId?: string }>()
- .catch(() => ({}) as { sourceTabId?: string });
- const sourceTabId = body.sourceTabId;
- if (!sourceTabId || typeof sourceTabId !== "string") {
- return c.json({ error: "sourceTabId is required" }, 400);
- }
- const mgr = getAgentManager();
- if (!mgr) return c.json({ error: "agent manager unavailable" }, 503);
- // Run in the background; outcome is emitted over WS.
- void mgr.compactTab(tempTabId, sourceTabId).catch((err) => {
- console.error(`[dispatch] compactTab error for ${sourceTabId}:`, err);
- });
- return c.json({ success: true }, 202);
-});
-
-tabsRoutes.patch("/:id", async (c) => {
- const id = c.req.param("id");
- const body = await c.req.json<{
- title?: string;
- keyId?: string;
- modelId?: string;
- status?: string;
- }>();
- if (body.title !== undefined) updateTabTitle(id, body.title);
- if (body.keyId !== undefined || body.modelId !== undefined) {
- updateTabModel(id, body.keyId ?? null, body.modelId ?? null);
- }
- if (body.status !== undefined) updateTabStatus(id, body.status);
- const tab = getTab(id);
- return c.json(tab);
-});
-
-// ─── Settings ─────────────────────────────────────────────────
-
-tabsRoutes.get("/settings/:key", (c) => {
- const key = c.req.param("key");
- const value = getSetting(key);
- return c.json({ value });
-});
-
-tabsRoutes.put("/settings/:key", async (c) => {
- const key = c.req.param("key");
- const body = await c.req.json<{ value?: string }>();
- if (typeof body.value !== "string") {
- return c.json({ error: "value is required" }, 400);
- }
- setSetting(key, body.value);
- return c.json({ success: true });
-});
-
-tabsRoutes.delete("/:id", (c) => {
- const id = c.req.param("id");
- const mgr = getAgentManager();
- if (mgr) mgr.deleteTab(id);
- archiveTab(id);
- return c.json({ success: true });
-});
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
deleted file mode 100644
index a88e41b..0000000
--- a/packages/api/src/types.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export types from @dispatch/core for convenience
-export type { AgentEvent, AgentStatus } from "@dispatch/core";
diff --git a/packages/api/src/wake-scheduler.ts b/packages/api/src/wake-scheduler.ts
deleted file mode 100644
index 8953e9f..0000000
--- a/packages/api/src/wake-scheduler.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Pure helpers for the Claude wake scheduler. Kept side-effect-free so the
- * recovery & rescheduling logic can be unit-tested without spinning up the
- * Hono app or touching SQLite.
- *
- * Semantics — read this before editing:
- *
- * 1. The user marks an hour (0-23) on the frontend. Marking the hour
- * schedules FOUR probes inside that hour, one per quarter-hour slot
- * (:00, :15, :30, :45). Each slot is its own persisted row keyed by
- * (hour, slot_minute). The frontend computes the *first* fire ms for
- * each slot in **its** local timezone and sends them; that absolute
- * ms is the source of truth.
- *
- * 2. After each fire (successful or not) we advance the slot by exactly
- * 24h from the previous `next_wake_at`. This preserves the user's
- * original local wall-clock intent regardless of the *server*'s
- * timezone. DST can drift the fire by ±1h on transition day; it
- * self-corrects the next time the user toggles the hour.
- *
- * 3. On server boot, any persisted slot whose `next_wake_at` is in the
- * past is "recovered": if it was missed by ≤ MISSED_WAKE_GRACE_MS we
- * fire it on the next tick (signal: `shouldFireNow = true`) and
- * advance to the next future occurrence. If missed by more than the
- * grace window we silently skip and advance. Either way the slot
- * stays scheduled.
- *
- * 4. Multiple slots that come due in the same tick (or recover at
- * boot) coalesce into a SINGLE upstream wake call. Probing four
- * times in 15 minutes is fine; probing four times within the same
- * 30s tick is wasteful and pointless.
- */
-
-/** How long after a missed fire we still consider it worth running. */
-export const MISSED_WAKE_GRACE_MS = 2 * 60 * 60 * 1000; // 2 hours
-
-/** Day length used when advancing recurring wakes. */
-export const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000;
-
-/** Fixed offset (hours) from a wake to the "Claude session reset" display. */
-export const CLAUDE_RESET_OFFSET_HOURS = 5;
-
-/** Minute offsets inside a marked hour where a probe fires. */
-export const PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
-export type ProbeSlotMinute = (typeof PROBE_SLOT_MINUTES)[number];
-
-/**
- * Advance `previous` by 24-hour increments until strictly after `now`.
- * Pure: only does math on the given numbers.
- */
-export function nextDailyAfter(previous: number, now: number): number {
- if (previous > now) return previous;
- const deltaMs = now - previous;
- // Ceiling division so the result is strictly > now.
- const stepsAhead = Math.floor(deltaMs / DAILY_INTERVAL_MS) + 1;
- return previous + stepsAhead * DAILY_INTERVAL_MS;
-}
-
-export interface RecoveredEntry {
- /** New `next_wake_at` to persist (always strictly in the future). */
- nextWakeAt: number;
- /** True if the caller should fire a wake *right now* before scheduling. */
- shouldFireNow: boolean;
-}
-
-/**
- * Compute the post-boot state for a single persisted schedule entry.
- *
- * - Entry still in the future → keep as-is, no fire.
- * - Missed by ≤ grace window → fire now, then advance to next day.
- * - Missed by > grace window → skip the fire, advance to next day.
- */
-export function recoverScheduleEntry(
- storedNextWakeAt: number,
- now: number,
- graceMs: number = MISSED_WAKE_GRACE_MS,
-): RecoveredEntry {
- if (storedNextWakeAt > now) {
- return { nextWakeAt: storedNextWakeAt, shouldFireNow: false };
- }
- const overdueBy = now - storedNextWakeAt;
- const shouldFireNow = overdueBy <= graceMs;
- return {
- nextWakeAt: nextDailyAfter(storedNextWakeAt, now),
- shouldFireNow,
- };
-}
-
-/** Display hour (0-23) for the "reset" label paired with a wake hour. */
-export function resetHourFor(wakeHour: number): number {
- return (wakeHour + CLAUDE_RESET_OFFSET_HOURS) % 24;
-}
-
-/** Type guard: is this number a valid probe slot minute? */
-export function isProbeSlotMinute(n: unknown): n is ProbeSlotMinute {
- return n === 0 || n === 15 || n === 30 || n === 45;
-}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
deleted file mode 100644
index 7d8342e..0000000
--- a/packages/api/tests/agent-manager.test.ts
+++ /dev/null
@@ -1,2142 +0,0 @@
-import type { AgentEvent, ToolDefinition } from "@dispatch/core";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// Spy on appendEventToChunks so we can assert persistence calls
-const appendEventToChunksSpy = vi.fn((_chunks: unknown[], _event: unknown) => {
- // no-op; we inspect calls in tests
-});
-
-// Configurable stub for `getMessagesForTab`. Tests can push rows
-// before invoking `processMessage` to simulate prior conversation
-// history persisted in the DB (model-switch / history-replay path).
-interface FakeMessageRow {
- id: string;
- tabId: string;
- seq: number;
- role: "user" | "assistant" | "system";
- chunks: unknown[];
- createdAt: number;
-}
-const fakeMessagesByTab = new Map<string, FakeMessageRow[]>();
-function resetFakeMessages(): void {
- fakeMessagesByTab.clear();
-}
-function setFakeMessages(tabId: string, rows: FakeMessageRow[]): void {
- fakeMessagesByTab.set(tabId, rows);
-}
-
-// Configurable stub for the tabs DB (getTab / listOpenTabs). Tests can seed
-// rows to exercise deliverMessage cold-hydration and handle resolution.
-interface FakeTabRow {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-const fakeTabs = new Map<string, FakeTabRow>();
-function resetFakeTabs(): void {
- fakeTabs.clear();
-}
-function setFakeTab(row: Partial<FakeTabRow> & { id: string }): void {
- fakeTabs.set(row.id, {
- title: "Tab",
- keyId: null,
- modelId: null,
- parentTabId: null,
- status: "idle",
- isOpen: true,
- position: 0,
- createdAt: 0,
- updatedAt: 0,
- ...row,
- });
-}
-function makeRow(
- tabId: string,
- seq: number,
- role: "user" | "assistant" | "system",
- chunks: unknown[],
-): FakeMessageRow {
- return { id: `msg-${tabId}-${seq}`, tabId, seq, role, chunks, createdAt: seq };
-}
-
-// Hook into Agent construction so tests can assert what
-// `messages` was pre-populated with at the moment `run()` was
-// called (after the post-construction pre-populate step in
-// `getOrCreateAgentForTab` has had a chance to assign).
-//
-// We snapshot at `run()` invocation rather than at construction
-// because the production code reassigns `agent.messages =
-// rows.slice(...)` AFTER `new Agent()` returns — capturing a
-// reference at construction would yield a stale empty array.
-const constructedAgents: Array<{
- initialMessages: unknown[];
- toolNames: string[];
- systemPrompt: string;
-}> = [];
-function resetConstructedAgents(): void {
- constructedAgents.length = 0;
-}
-
-// Capture the per-call `run()` options (notably reasoningEffort) so tests can
-// assert the per-model → per-tab → default effort resolution.
-const capturedRunOptions: Array<{ reasoningEffort?: string } | undefined> = [];
-function resetCapturedRunOptions(): void {
- capturedRunOptions.length = 0;
-}
-
-// Capture every warmCache(history) call so tests can assert the warming replay
-// receives the genuine (FULL) history and returns its usage unmodified.
-const capturedWarmHistories: unknown[][] = [];
-function resetCapturedWarmHistories(): void {
- capturedWarmHistories.length = 0;
-}
-
-// Configurable settings store so tests can toggle tool permissions
-// (perm_send_to_tab / perm_read_tab / ...) and assert which tools the
-// constructed Agent receives. Defaults to empty (getSetting → null).
-const fakeSettings = new Map<string, string>();
-function resetFakeSettings(): void {
- fakeSettings.clear();
-}
-function setFakeSetting(key: string, value: string): void {
- fakeSettings.set(key, value);
-}
-
-// Capture every appendChunks(tabId, drafts) call so tests can assert what got
-// persisted (e.g. usage side-channel rows). The real explodeTurn is mocked to
-// return [], so content drafts are empty here; usage rows are pushed directly
-// by processMessage's flushAssistant, making them the visible drafts.
-interface AppendChunksCall {
- tabId: string;
- drafts: Array<{ turnId: string; step: number; role: string; type: string; data: unknown }>;
-}
-const appendChunksCalls: AppendChunksCall[] = [];
-function resetAppendChunksCalls(): void {
- appendChunksCalls.length = 0;
-}
-
-// ── Compaction test scaffolding ────────────────────────────────────
-// Fake chunk store (per tab) feeding getChunksForTab / rekeyChunks.
-const fakeChunksByTab = new Map<string, Array<{ tabId: string }>>();
-// Records of createTab(id, title, opts) and rekeyChunks(from, to) calls.
-const createTabCalls: Array<{ id: string; title: string }> = [];
-const rekeyCalls: Array<{ from: string; to: string }> = [];
-// Configurable buildCompactionRequest result per source tab. Default: a
-// compactable conversation (non-empty prompt + a one-message tail).
-const fakeCompactionByTab = new Map<
- string,
- { prompt?: string; tail: Array<{ turnId: string; role: string; chunks: unknown[] }> }
->();
-// Configurable registry keys + env-key resolution so resolveConnection can
-// succeed in compaction tests. Default empty (preserves existing behaviour).
-const fakeRegistryKeys: Array<{
- id: string;
- provider: string;
- base_url: string;
- env?: string;
-}> = [];
-const fakeApiKeys = new Map<string, string>();
-const fakeConfigKeys: Array<{ id: string; provider: string; base_url: string; env?: string }> = [];
-function resetCompactionScaffolding(): void {
- fakeRegistryKeys.length = 0;
- fakeApiKeys.clear();
- fakeConfigKeys.length = 0;
- fakeChunksByTab.clear();
- createTabCalls.length = 0;
- rekeyCalls.length = 0;
- fakeCompactionByTab.clear();
-}
-
-// Seedable return value for the mocked getUsageStatsForTab — what the backend
-// reads (post-write) to attach to the `turn-sealed` event.
-const fakeUsageStatsByTab = new Map<string, unknown>();
-function resetFakeUsageStats(): void {
- fakeUsageStatsByTab.clear();
-}
-
-// Allow tests to swap in a custom `run` generator (e.g. to simulate
-// a fallback failure mid-stream). Returning to undefined restores
-// the default.
-type RunGen = (msg: string) => AsyncGenerator<unknown>;
-let runImpl: RunGen | null = null;
-function setRunImpl(impl: RunGen | null): void {
- runImpl = impl;
-}
-async function* defaultRun(_message: string): AsyncGenerator<unknown> {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 10));
- yield { type: "reasoning-delta", delta: "thinking about it" } as const;
- yield {
- type: "reasoning-end",
- metadata: { anthropic: { signature: "mock-sig" } },
- } as const;
- yield { type: "text-delta", delta: "Hello " } as const;
- yield { type: "text-delta", delta: "world" } as const;
- yield {
- type: "done",
- message: {
- role: "assistant",
- chunks: [
- {
- type: "thinking",
- text: "thinking about it",
- metadata: { anthropic: { signature: "mock-sig" } },
- },
- { type: "text", text: "Hello world" },
- ],
- },
- } as const;
- yield { type: "status", status: "idle" } as const;
-}
-
-// Mock @dispatch/core's Agent to avoid real LLM calls
-vi.mock("@dispatch/core", () => ({
- Agent: class MockAgent {
- status = "idle";
- messages: unknown[] = [];
- toolNames: string[] = [];
- systemPrompt = "";
- constructor(config: { tools?: Array<{ name: string }>; systemPrompt?: string }) {
- this.toolNames = (config?.tools ?? []).map((t) => t.name);
- this.systemPrompt = config?.systemPrompt ?? "";
- }
- async *run(message: string, options?: { reasoningEffort?: string }): AsyncGenerator<unknown> {
- // Snapshot the post-construction pre-populated message list
- // the first thing `run()` does, before the real `Agent.run`
- // would push the current user message at line 546. Tests
- // inspect this to verify history was loaded correctly.
- constructedAgents.push({
- initialMessages: [...this.messages],
- toolNames: [...this.toolNames],
- systemPrompt: this.systemPrompt,
- });
- capturedRunOptions.push(options);
- if (runImpl) {
- for await (const ev of runImpl(message)) yield ev;
- return;
- }
- for await (const ev of defaultRun(message)) yield ev;
- }
- async warmCache(history: unknown[]) {
- capturedWarmHistories.push([...history]);
- return {
- inputTokens: 1200,
- outputTokens: 1,
- cacheReadTokens: 1100,
- cacheWriteTokens: 0,
- };
- }
- },
- PermissionService: class MockPermissionService {
- ask(_request: unknown, _rulesets: unknown[]) {
- return Promise.resolve("once");
- }
- reply(_id: string, _reply: unknown) {}
- getPending() {
- return [];
- }
- },
- createReadFileTool(_wd: string): ToolDefinition {
- return {
- name: "read_file",
- description: "read a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock file content",
- };
- },
- createReadFileSliceTool(_wd: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description: "read a char slice of a single line",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock slice",
- };
- },
- clearSpillForTab(_tabId: string) {},
- createWriteFileTool(_wd: string): ToolDefinition {
- return {
- name: "write_file",
- description: "write a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => true,
- };
- },
- createListFilesTool(_wd: string): ToolDefinition {
- return {
- name: "list_files",
- description: "list files",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ["file1.ts"],
- };
- },
- createLspTool(_getContext: unknown): ToolDefinition {
- return {
- name: "lsp",
- description: "query the language server",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock lsp",
- };
- },
- LspManager: class MockLspManager {
- hasServerForFile() {
- return false;
- }
- async getClients() {
- return [];
- }
- async touchFile() {}
- getDiagnostics() {
- return {};
- }
- async request() {
- return [];
- }
- async shutdownAll() {}
- },
- resolveServersFromConfig(_lsp: unknown) {
- return [];
- },
- reportDiagnostics(_file: string, _issues: unknown) {
- return "";
- },
- createRunShellTool(_wd: string): ToolDefinition {
- return {
- name: "run_shell",
- description: "run shell command",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
- };
- },
- loadConfig(_dir: string) {
- return fakeConfigKeys.length > 0
- ? { permissions: {}, keys: [...fakeConfigKeys] }
- : { permissions: {} };
- },
- configToRuleset(_config: unknown) {
- return [];
- },
- validateConfig(_config: unknown) {
- return { config: _config, errors: [] };
- },
- createConfigWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- watchDirConfig(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- loadSkills(_dir: string) {
- return { skills: [], mappings: [] };
- },
- createSkillsWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- ModelRegistry: class MockModelRegistry {
- getModels() {
- return [];
- }
- getKeys() {
- return fakeRegistryKeys.map((k) => ({ definition: k, status: "active" }));
- }
- getModelsByTag(_tag: string) {
- return [];
- }
- getAllTags() {
- return [];
- }
- hasAvailableKey(_provider: string) {
- return false;
- }
- allKeysExhausted() {
- return true;
- }
- markKeyExhausted() {}
- markKeyActive() {}
- updateConfig() {}
- },
- ModelResolver: class MockModelResolver {
- resolve(_tag: string) {
- return null;
- }
- waitForKey() {
- return Promise.resolve(null);
- }
- },
- TaskList: class MockTaskList {
- private tasks: Array<{ id: string; content: string; status: string }> = [];
- getTasks() {
- return this.tasks.map((t) => ({ ...t }));
- }
- setTasks(items: Array<{ content: string; status?: string }>) {
- this.tasks = items.map((item, i) => ({
- id: `task-${i + 1}`,
- content: item.content,
- status: item.status ?? "pending",
- }));
- return this.getTasks();
- }
- onChange(_cb: unknown) {
- return () => {};
- }
- },
- createTaskListTool(_taskList: unknown) {
- return {
- name: "todo",
- description: "todo",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSummonTool(_wd: string, _callbacks: unknown) {
- return {
- name: "summon",
- description: "summon",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createRetrieveTool(_callbacks: unknown) {
- return {
- name: "retrieve",
- description: "retrieve",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- // Summon parent-path dependencies. The real implementations load agent
- // definitions from disk; tests only need the summon/retrieve tool entries
- // to appear, so these return empty projections.
- loadAgents() {
- return [];
- },
- toAvailableSubagents() {
- return [];
- },
- toAvailableUserAgents() {
- return [];
- },
- getAgentDirPaths() {
- return [];
- },
- GLOBAL_AGENTS_DIR: "/tmp/global-agents",
- createTab(id: string, title: string) {
- createTabCalls.push({ id, title });
- return { id, title };
- },
- getTab(id: string) {
- return fakeTabs.get(id) ?? null;
- },
- listOpenTabs() {
- return [...fakeTabs.values()].filter((t) => t.isOpen);
- },
- resolveTabPrefix(prefix: string) {
- const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
- if (sanitized.length < 4) return { status: "none" };
- const matches = [...fakeTabs.values()].filter(
- (t) => t.isOpen && t.id.toLowerCase().startsWith(sanitized),
- );
- if (matches.length === 0) return { status: "none" };
- if (matches.length === 1) return { status: "ok", tab: matches[0] };
- return { status: "ambiguous", matches };
- },
- shortestUniquePrefix(id: string) {
- return (id ?? "").slice(0, 4);
- },
- createSendToTabTool(_callbacks: unknown): ToolDefinition {
- return {
- name: "send_to_tab",
- description: "send to tab",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock",
- };
- },
- createReadTabTool(_callbacks: unknown): ToolDefinition {
- return {
- name: "read_tab",
- description: "read tab",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock",
- };
- },
- getClaudeAccountsFromDB() {
- return [];
- },
- refreshAccountCredentials() {
- return null;
- },
- refreshAccountCredentialsAsync() {
- return Promise.resolve(null);
- },
- resolveApiKey(keyId: string) {
- return fakeApiKeys.get(keyId) ?? null;
- },
- getSetting(key: string) {
- return fakeSettings.get(key) ?? null;
- },
- isReasoningEffort(value: unknown) {
- return (
- typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value)
- );
- },
- appendChunks(tabId: string, drafts: AppendChunksCall["drafts"]) {
- appendChunksCalls.push({ tabId, drafts: [...drafts] });
- return [];
- },
- explodeUserText() {
- return [];
- },
- explodeTurn() {
- return [{ turnId: "t", step: 0, role: "assistant", type: "text", data: { text: "" } }];
- },
- getChunksForTab(tabId: string) {
- return fakeChunksByTab.get(tabId) ?? [];
- },
- groupRowsToMessages(rows: Array<{ tabId: string }>) {
- return rows;
- },
- rekeyChunks(from: string, to: string) {
- rekeyCalls.push({ from, to });
- const rows = fakeChunksByTab.get(from) ?? [];
- fakeChunksByTab.set(to, rows);
- fakeChunksByTab.delete(from);
- return rows.length;
- },
- buildCompactionRequest(input: { messages: unknown[] }) {
- // Resolve the seeded result by matching the first row's tabId.
- const first = (input.messages as Array<{ tabId?: string }>)[0];
- const tabId = first?.tabId ?? "";
- const seeded = fakeCompactionByTab.get(tabId);
- if (seeded) return { head: [], tail: seeded.tail, prompt: seeded.prompt };
- return {
- head: [],
- tail: [{ turnId: "tail-turn", role: "user", chunks: [{ type: "text", text: "recent" }] }],
- prompt: "SUMMARY PROMPT",
- };
- },
- buildSummaryTurnText(summary: string) {
- return `[CONVERSATION SUMMARY]\n\n${summary}`;
- },
- getMessagesForTab(tabId: string) {
- return fakeMessagesByTab.get(tabId) ?? [];
- },
- getUsageStatsForTab(tabId: string) {
- return fakeUsageStatsByTab.get(tabId) ?? null;
- },
- appendEventToChunks: appendEventToChunksSpy,
- applySystemEvent(_messages: unknown[], _event: unknown) {
- return { messageId: "mock-system-msg" };
- },
- BackgroundShellStore: class MockBackgroundShellStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- BackgroundTranscriptStore: class MockBackgroundTranscriptStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- createWebSearchTool() {
- return {
- name: "web_search",
- description: "web search",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createKeyUsageTool(_callbacks: unknown) {
- return {
- name: "key_usage",
- description: "key usage",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSearchCodeTool(_wd: string) {
- return {
- name: "search_code",
- description: "search code",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createYoutubeTranscribeTool() {
- return {
- name: "youtube_transcribe",
- description: "youtube transcribe",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
-}));
-
-// Import after mock is defined (Vitest hoists vi.mock automatically)
-const { AgentManager } = await import("../src/agent-manager.js");
-
-describe("AgentManager", () => {
- beforeEach(() => {
- resetFakeMessages();
- resetConstructedAgents();
- resetCapturedRunOptions();
- resetFakeTabs();
- resetFakeSettings();
- setRunImpl(null);
- appendEventToChunksSpy.mockClear();
- resetAppendChunksCalls();
- resetFakeUsageStats();
- resetCapturedWarmHistories();
- resetCompactionScaffolding();
- });
-
- it("initial status is idle", () => {
- const manager = new AgentManager();
- expect(manager.getStatus()).toBe("idle");
- });
-
- it("initial messageCount is 0", () => {
- const manager = new AgentManager();
- expect(manager.getMessageCount()).toBe(0);
- });
-
- it("event listeners receive events during processMessage", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-1", "test");
-
- expect(events.length).toBeGreaterThan(0);
- // A turn now opens with `turn-start`, immediately followed by the
- // agent's `status: running`.
- expect(events[0]).toMatchObject({ type: "turn-start" });
- expect(events[1]).toMatchObject({ type: "status", status: "running" });
-
- // A turn now closes with `turn-sealed` (emitted after the DB write, which
- // is after the agent's final `status: idle`).
- const lastEvent = events[events.length - 1];
- expect(lastEvent).toMatchObject({ type: "turn-sealed" });
- expect(events.some((e) => e.type === "status" && e.status === "idle")).toBe(true);
-
- const doneEvent = events.find((e) => e.type === "done");
- expect(doneEvent).toBeDefined();
- });
-
- it("emits a turn-start with a turnId before any content event", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-turnstart", "go");
-
- const turnStartIdx = events.findIndex((e) => e.type === "turn-start");
- expect(turnStartIdx).toBeGreaterThanOrEqual(0);
- const turnStart = events[turnStartIdx] as Extract<AgentEvent, { type: "turn-start" }>;
- expect(typeof turnStart.turnId).toBe("string");
- expect(turnStart.turnId.length).toBeGreaterThan(0);
-
- // Must precede the first content delta.
- const firstContentIdx = events.findIndex(
- (e) => e.type === "text-delta" || e.type === "reasoning-delta",
- );
- expect(firstContentIdx).toBeGreaterThan(turnStartIdx);
- });
-
- it("emits text-delta events during processMessage", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-1", "hello");
-
- const textDeltas = events.filter((e) => e.type === "text-delta");
- expect(textDeltas.length).toBeGreaterThan(0);
- });
-
- it("messageCount increments after processMessage", async () => {
- const manager = new AgentManager();
- await manager.processMessage("tab-1", "hello");
- expect(manager.getMessageCount()).toBe(1);
- await manager.processMessage("tab-1", "world");
- expect(manager.getMessageCount()).toBe(2);
- });
-
- it("status returns to idle after processMessage completes", async () => {
- const manager = new AgentManager();
- await manager.processMessage("tab-1", "test");
- expect(manager.getStatus()).toBe("idle");
- });
-
- it("unsubscribe removes listener", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- const unsubscribe = manager.onEvent((event) => {
- events.push(event);
- });
-
- unsubscribe();
- await manager.processMessage("tab-1", "test");
-
- expect(events.length).toBe(0);
- });
-
- it("multiple listeners all receive events", async () => {
- const manager = new AgentManager();
- const listener1 = vi.fn();
- const listener2 = vi.fn();
-
- manager.onEvent(listener1);
- manager.onEvent(listener2);
-
- await manager.processMessage("tab-1", "test");
-
- expect(listener1).toHaveBeenCalled();
- expect(listener2).toHaveBeenCalled();
- });
-
- // ─── per-model reasoning effort precedence ───────────────────────
-
- describe("reasoning effort precedence (per-model → per-tab → default)", () => {
- it("uses the per-model effort over the per-tab selector for that fallback entry", async () => {
- const manager = new AgentManager();
- // Agent definition supplies a fallback chain where each entry has its
- // own configured effort; the per-tab selector ("low") must NOT win.
- await manager.processMessage(
- "tab-effort-permodel",
- "go",
- "key-a",
- "model-a",
- "low",
- undefined,
- [{ key_id: "key-a", model_id: "model-a", effort: "xhigh" }],
- );
- expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("xhigh");
- });
-
- it("falls back to the per-tab selector when the model entry has no effort", async () => {
- const manager = new AgentManager();
- await manager.processMessage(
- "tab-effort-tab",
- "go",
- "key-a",
- "model-a",
- "medium",
- undefined,
- [{ key_id: "key-a", model_id: "model-a" }],
- );
- expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("medium");
- });
-
- it("passes no effort (Agent applies its default) when neither is set", async () => {
- const manager = new AgentManager();
- await manager.processMessage(
- "tab-effort-default",
- "go",
- "key-a",
- "model-a",
- undefined,
- undefined,
- [{ key_id: "key-a", model_id: "model-a" }],
- );
- expect(capturedRunOptions.at(-1)?.reasoningEffort).toBeUndefined();
- });
- });
-
- // ─── v6 reasoning-end tests ───────────────────────────────────────
-
- it("reasoning-end event is broadcast to WS listeners", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-reasoning", "think please");
-
- const reasoningEndEvents = events.filter((e) => e.type === "reasoning-end");
- expect(reasoningEndEvents.length).toBeGreaterThan(0);
- expect(reasoningEndEvents[0]).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "mock-sig" } },
- });
- });
-
- it("reasoning-end is passed to appendEventToChunks for persistence", async () => {
- appendEventToChunksSpy.mockClear();
- const manager = new AgentManager();
-
- await manager.processMessage("tab-persist", "think and persist");
-
- // Find all calls to appendEventToChunks that received a reasoning-end event
- const reasoningEndCalls = appendEventToChunksSpy.mock.calls.filter(
- ([_chunks, event]) => (event as AgentEvent).type === "reasoning-end",
- );
- expect(reasoningEndCalls.length).toBeGreaterThan(0);
-
- // The event should carry the metadata blob
- const [, reasoningEndEvent] = reasoningEndCalls[0] as [unknown[], AgentEvent];
- expect(reasoningEndEvent).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "mock-sig" } },
- });
- });
-
- it("reasoning-end follows reasoning-delta in broadcast order (chunk accumulator ordering)", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-ordering", "think in order");
-
- const types = events.map((e) => e.type);
- const deltaIdx = types.indexOf("reasoning-delta");
- const endIdx = types.indexOf("reasoning-end");
-
- // Both must be present
- expect(deltaIdx).toBeGreaterThanOrEqual(0);
- expect(endIdx).toBeGreaterThanOrEqual(0);
-
- // reasoning-end must come AFTER reasoning-delta
- expect(endIdx).toBeGreaterThan(deltaIdx);
-
- // reasoning-end must come BEFORE any text-delta (reasoning precedes text)
- const textDeltaIdx = types.indexOf("text-delta");
- if (textDeltaIdx >= 0) {
- expect(endIdx).toBeLessThan(textDeltaIdx);
- }
- });
-
- it("done event includes a thinking chunk with metadata in its message", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-done-chunks", "think and respond");
-
- const doneEvent = events.find((e) => e.type === "done") as
- | Extract<AgentEvent, { type: "done" }>
- | undefined;
- expect(doneEvent).toBeDefined();
-
- const thinkingChunk = doneEvent?.message.chunks.find((c) => c.type === "thinking");
- expect(thinkingChunk).toBeDefined();
- expect(thinkingChunk).toMatchObject({
- type: "thinking",
- text: "thinking about it",
- metadata: { anthropic: { signature: "mock-sig" } },
- });
- });
-
- // ─── History pre-population on Agent (re)construction ────────────
- //
- // These tests guard the fix that prior conversation turns survive
- // switching models mid-conversation via the sidebar slider. Without
- // it, a fresh `Agent` is constructed with `messages: []` and the
- // next LLM call sees zero prior context.
-
- it("pre-populates Agent.messages from DB history when constructing a fresh Agent", async () => {
- const manager = new AgentManager();
- const tabId = "tab-history";
-
- // Simulate prior conversation in the DB:
- // u1, a1, u_current
- // (the current turn's user message has already been appended
- // by `processMessage` before `getOrCreateAgentForTab` runs)
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "first question" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "first answer" }]),
- makeRow(tabId, 2, "user", [{ type: "text", text: "follow-up" }]),
- ]);
-
- await manager.processMessage(tabId, "follow-up");
-
- // Exactly one Agent should have been constructed for this tab,
- // and its messages must be the prior two rows (excluding the
- // current user message — `Agent.run()` pushes that itself).
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- const init = inst.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- expect(init.length).toBe(2);
- expect(init[0]).toMatchObject({
- role: "user",
- chunks: [{ type: "text", text: "first question" }],
- });
- expect(init[1]).toMatchObject({
- role: "assistant",
- chunks: [{ type: "text", text: "first answer" }],
- });
- });
-
- it("leaves messages empty when the DB has only the current turn's user message (first turn)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-first-turn";
-
- // First-ever turn: DB has only the just-appended user message.
- setFakeMessages(tabId, [makeRow(tabId, 0, "user", [{ type: "text", text: "hello" }])]);
-
- await manager.processMessage(tabId, "hello");
-
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- // The user message at idx 0 is the current turn — must be excluded.
- expect((inst.initialMessages as unknown[]).length).toBe(0);
- });
-
- it("excludes a partial assistant trail from a prior fallback attempt", async () => {
- const manager = new AgentManager();
- const tabId = "tab-fallback-partial";
-
- // Scenario: the agent-mode fallback path. Attempt 1 (Opus) errored
- // mid-stream after flushing some chunks; attempt 2 (DeepSeek) is
- // about to start. DB looks like:
- // u1, a1, u_current, partial_a_attempt1
- // The fresh Agent for attempt 2 must see [u1, a1] — not the
- // current user message and not the failed attempt's partial.
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "a1" }]),
- makeRow(tabId, 2, "user", [{ type: "text", text: "q2" }]),
- makeRow(tabId, 3, "assistant", [{ type: "text", text: "half-baked..." }]),
- ]);
-
- await manager.processMessage(tabId, "q2");
-
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- const init = inst.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- expect(init.length).toBe(2);
- expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
- expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
- });
-
- it("preserves system-role rows in pre-populated history (toModelMessages filters them later)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-with-system-rows";
-
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "a1" }]),
- makeRow(tabId, 2, "system", [
- { type: "system", kind: "config-reload", text: "Configuration reloaded" },
- ]),
- makeRow(tabId, 3, "user", [{ type: "text", text: "q2" }]),
- ]);
-
- await manager.processMessage(tabId, "q2");
-
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- const init = inst.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- // All three prior rows (user/assistant/system) preserved; the
- // LLM-facing `toModelMessages` strips the system row later.
- expect(init.length).toBe(3);
- expect(init[2]).toMatchObject({ role: "system" });
- });
-
- it("survives a getMessagesForTab failure without crashing (messages stays empty)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-db-error";
-
- // Simulate DB error by stubbing the fake-store getter to throw
- // for this specific tab. We use a Proxy on the Map's get method
- // for the duration of one call.
- const realGet = fakeMessagesByTab.get.bind(fakeMessagesByTab);
- fakeMessagesByTab.get = ((key: string) => {
- if (key === tabId) throw new Error("simulated DB error");
- return realGet(key);
- }) as typeof fakeMessagesByTab.get;
-
- try {
- await expect(manager.processMessage(tabId, "anything")).resolves.toBeUndefined();
- } finally {
- fakeMessagesByTab.get = realGet;
- }
-
- // Agent still constructed, just with empty messages.
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- expect((inst.initialMessages as unknown[]).length).toBe(0);
- });
-
- it("reloads history on every Agent reconstruction (simulated model switch)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-model-switch";
-
- // Turn 1: empty DB → just the first user message.
- setFakeMessages(tabId, [makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }])]);
- await manager.processMessage(tabId, "q1", "key-opus", "claude-opus-4-7");
-
- // Turn 2: DB now has the full prior turn + new user message.
- // User has switched models via the sidebar slider — different
- // (keyId, modelId) triggers Agent invalidation and reconstruction.
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "a1" }]),
- makeRow(tabId, 2, "user", [{ type: "text", text: "q2" }]),
- ]);
- await manager.processMessage(tabId, "q2", "key-deepseek", "deepseek-v3");
-
- // Exactly two Agents constructed across the two turns (the
- // invalidation gate fires when keyId/modelId change).
- expect(constructedAgents.length).toBe(2);
-
- // Second Agent (the DeepSeek one) was pre-populated with the
- // completed first turn — not empty, not duplicating q2.
- const second = constructedAgents[1];
- expect(second).toBeDefined();
- if (!second) return;
- const init = second.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- expect(init.length).toBe(2);
- expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
- expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
- });
-
- // ─── getAllStatuses snapshot shape (for browser-reopen restore) ────
- //
- // The snapshot enriches the legacy `Record<string, AgentStatus>` shape
- // with per-tab in-flight context so a fresh frontend can render the
- // streaming assistant message correctly after a reload.
-
- it("getAllStatuses returns an empty record when no tabs are tracked", () => {
- const manager = new AgentManager();
- expect(manager.getAllStatuses()).toEqual({});
- });
-
- it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => {
- const manager = new AgentManager();
- // Drive a full turn so the tab gets registered; default mock run
- // settles back to idle by the time `await` resolves.
- await manager.processMessage("tab-idle", "hi");
- const snap = manager.getAllStatuses();
- expect(snap["tab-idle"]).toBeDefined();
- expect(snap["tab-idle"]?.status).toBe("idle");
- expect(snap["tab-idle"]).not.toHaveProperty("currentChunks");
- expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId");
- });
-
- it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => {
- const manager = new AgentManager();
- // Reach into the private map to set up a synthetic running state.
- // Justification: there is no public API to enter a sustained
- // "running" state without actually streaming, and we want to
- // assert the snapshot shape — not the streaming pipeline.
- const inner = manager as unknown as {
- tabAgents: Map<
- string,
- {
- agent: null;
- status: "running" | "idle" | "error";
- keyId: null;
- modelId: null;
- taskList: { onChange: (cb: unknown) => void; getTasks: () => unknown[] };
- messageQueue: unknown[];
- queueListeners: unknown[];
- shellStore: unknown;
- transcriptStore: unknown;
- currentChunks: Array<{ type: string; text?: string }> | null;
- currentAssistantId: string | null;
- }
- >;
- };
- inner.tabAgents.set("tab-running", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: [
- { type: "thinking", text: "let me think" },
- { type: "text", text: "partial answer" },
- ],
- currentAssistantId: "assistant-msg-id-7",
- });
-
- const snap = manager.getAllStatuses();
- expect(snap["tab-running"]).toBeDefined();
- expect(snap["tab-running"]?.status).toBe("running");
- expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7");
- expect(snap["tab-running"]?.currentChunks).toEqual([
- { type: "thinking", text: "let me think" },
- { type: "text", text: "partial answer" },
- ]);
- });
-
- it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<
- string,
- {
- agent: null;
- status: "running";
- keyId: null;
- modelId: null;
- taskList: { onChange: (cb: unknown) => void; getTasks: () => unknown[] };
- messageQueue: unknown[];
- queueListeners: unknown[];
- shellStore: unknown;
- transcriptStore: unknown;
- currentChunks: Array<{ type: string; text?: string }>;
- currentAssistantId: string;
- }
- >;
- };
- const liveChunks = [{ type: "text", text: "live" }];
- inner.tabAgents.set("tab-copy", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: liveChunks,
- currentAssistantId: "msg-x",
- });
-
- const snap = manager.getAllStatuses();
- // Mutate the snapshot's array
- snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" });
- // Live array must be untouched
- expect(liveChunks).toEqual([{ type: "text", text: "live" }]);
- });
-
- it("getAllStatuses omits currentChunks when a running tab has none yet", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<
- string,
- {
- agent: null;
- status: "running";
- keyId: null;
- modelId: null;
- taskList: { onChange: (cb: unknown) => void; getTasks: () => unknown[] };
- messageQueue: unknown[];
- queueListeners: unknown[];
- shellStore: unknown;
- transcriptStore: unknown;
- currentChunks: null;
- currentAssistantId: null;
- }
- >;
- };
- inner.tabAgents.set("tab-early", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: null,
- currentAssistantId: null,
- });
-
- const snap = manager.getAllStatuses();
- expect(snap["tab-early"]?.status).toBe("running");
- expect(snap["tab-early"]).not.toHaveProperty("currentChunks");
- expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId");
- });
-
- it("getAllStatuses includes a tab's todo list (for reload rehydration)", () => {
- const manager = new AgentManager();
- // Public API: getTaskList creates+returns the tab's list. setTasks is
- // the declarative whole-list write.
- const list = manager.getTaskList("tab-todos");
- list.setTasks([
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ]);
- const snap = manager.getAllStatuses();
- expect(snap["tab-todos"]?.tasks).toEqual([
- { id: "task-1", content: "plan", status: "completed" },
- { id: "task-2", content: "build", status: "in_progress" },
- ]);
- });
-
- it("getAllStatuses omits tasks for a tab with an empty todo list", () => {
- const manager = new AgentManager();
- manager.getTaskList("tab-empty");
- const snap = manager.getAllStatuses();
- expect(snap["tab-empty"]).toBeDefined();
- expect(snap["tab-empty"]).not.toHaveProperty("tasks");
- });
-
- // ─── Tab-to-tab communication ─────────────────────────────────
-
- describe("deliverMessage", () => {
- it("starts a new turn when the target tab is idle", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- const outcome = manager.deliverMessage("tab-idle", "wake up");
- expect(outcome.status).toBe("started");
-
- // Let the background turn run to completion.
- await new Promise<void>((r) => setTimeout(r, 60));
- expect(events.some((e) => e.type === "text-delta")).toBe(true);
- expect(manager.getTabStatus("tab-idle")).toBe("idle");
- });
-
- it("queues the message when the target tab is running", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<string, Record<string, unknown>>;
- };
- // Seed a running tab agent directly.
- inner.tabAgents.set("tab-busy", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: null,
- currentAssistantId: null,
- currentTurnId: null,
- });
-
- const outcome = manager.deliverMessage("tab-busy", "queued msg");
- expect(outcome.status).toBe("queued");
- if (outcome.status === "queued") {
- expect(typeof outcome.messageId).toBe("string");
- }
- // The message landed on the running tab's queue.
- const agent = inner.tabAgents.get("tab-busy") as { messageQueue: unknown[] };
- expect(agent.messageQueue).toHaveLength(1);
- });
-
- it("hydrates key/model from the persisted tab row for a cold wake", () => {
- const manager = new AgentManager();
- setFakeTab({ id: "tab-cold", keyId: "persisted-key", modelId: "persisted-model" });
-
- // Spy on processMessage to capture the key/model deliverMessage
- // forwarded — asserting the hydration decision directly rather than
- // downstream tabAgent state (which the mocked ModelRegistry rewrites).
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- const outcome = manager.deliverMessage("tab-cold", "hello");
- expect(outcome.status).toBe("started");
-
- expect(spy).toHaveBeenCalledTimes(1);
- const args = spy.mock.calls[0] ?? [];
- expect(args[0]).toBe("tab-cold"); // tabId
- expect(args[1]).toBe("hello"); // message
- expect(args[2]).toBe("persisted-key"); // keyId hydrated from row
- expect(args[3]).toBe("persisted-model"); // modelId hydrated from row
- });
-
- it("prefers explicit opts over the persisted row on a cold wake", () => {
- const manager = new AgentManager();
- setFakeTab({ id: "tab-cold2", keyId: "row-key", modelId: "row-model" });
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- manager.deliverMessage("tab-cold2", "hello", {
- keyId: "explicit-key",
- modelId: "explicit-model",
- });
-
- const args = spy.mock.calls[0] ?? [];
- expect(args[2]).toBe("explicit-key");
- expect(args[3]).toBe("explicit-model");
- });
- });
-
- describe("deliverMessage — agent auto-wake budget", () => {
- it("allows up to 6 consecutive agent wakes, then suppresses further ones", () => {
- const manager = new AgentManager();
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- // 6 agent-originated wakes of an idle tab should all start turns.
- for (let i = 0; i < 6; i++) {
- const outcome = manager.deliverMessage("tab-pp", `msg ${i}`, { origin: "agent" });
- expect(outcome.status).toBe("started");
- }
- expect(spy).toHaveBeenCalledTimes(6);
-
- // The 7th is suppressed: no new turn, message preserved on the queue.
- const seventh = manager.deliverMessage("tab-pp", "msg 7", { origin: "agent" });
- expect(seventh.status).toBe("suppressed");
- expect(spy).toHaveBeenCalledTimes(6); // unchanged — no wake
-
- const inner = manager as unknown as {
- tabAgents: Map<string, { messageQueue: unknown[]; autoWakeBudget: number }>;
- };
- const agent = inner.tabAgents.get("tab-pp");
- expect(agent?.autoWakeBudget).toBe(0);
- // Suppressed message is queued, not dropped.
- expect(agent?.messageQueue).toHaveLength(1);
- });
-
- it("a human message refills the budget and re-enables agent wakes", () => {
- const manager = new AgentManager();
- vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- // Exhaust the budget with agent wakes.
- for (let i = 0; i < 6; i++) {
- manager.deliverMessage("tab-refill", `a${i}`, { origin: "agent" });
- }
- expect(manager.deliverMessage("tab-refill", "blocked", { origin: "agent" }).status).toBe(
- "suppressed",
- );
-
- // A human message refills the budget...
- const humanOutcome = manager.deliverMessage("tab-refill", "human here", {
- origin: "human",
- });
- expect(humanOutcome.status).toBe("started");
-
- const inner = manager as unknown as {
- tabAgents: Map<string, { autoWakeBudget: number }>;
- };
- expect(inner.tabAgents.get("tab-refill")?.autoWakeBudget).toBe(6);
-
- // ...so an agent can wake it again.
- expect(manager.deliverMessage("tab-refill", "again", { origin: "agent" }).status).toBe(
- "started",
- );
- });
-
- it("does not consume budget when the message is merely queued (busy target)", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<string, Record<string, unknown>>;
- };
- inner.tabAgents.set("tab-busy-budget", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: null,
- currentAssistantId: null,
- currentTurnId: null,
- autoWakeBudget: 6,
- });
-
- const outcome = manager.deliverMessage("tab-busy-budget", "queued one", {
- origin: "agent",
- });
- expect(outcome.status).toBe("queued");
- // Budget untouched — queuing can't drive a runaway loop.
- const agent = inner.tabAgents.get("tab-busy-budget") as { autoWakeBudget: number };
- expect(agent.autoWakeBudget).toBe(6);
- });
-
- it("human-originated wakes are never throttled", () => {
- const manager = new AgentManager();
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- // Far more than the budget, all human-originated → all start turns.
- for (let i = 0; i < 10; i++) {
- const outcome = manager.deliverMessage("tab-human", `h${i}`, { origin: "human" });
- expect(outcome.status).toBe("started");
- }
- expect(spy).toHaveBeenCalledTimes(10);
- });
-
- it("defaults origin to human when unspecified (POST /chat path)", () => {
- const manager = new AgentManager();
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
- for (let i = 0; i < 8; i++) {
- expect(manager.deliverMessage("tab-default", `d${i}`).status).toBe("started");
- }
- expect(spy).toHaveBeenCalledTimes(8);
- });
- });
-
- describe("queue continuation after a turn ends", () => {
- // A run generator that enqueues `msg` (as if a user/agent sent it mid-turn)
- // exactly once, then streams a normal short reply. Used to simulate a
- // message landing on the queue while the agent is busy.
- function runThatEnqueues(manager: AgentManager, tabId: string, msg: string): RunGen {
- let enqueued = false;
- return async function* () {
- yield { type: "status", status: "running" } as const;
- if (!enqueued) {
- enqueued = true;
- manager.queueMessage(tabId, msg);
- }
- yield { type: "text-delta", delta: "reply" } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "reply" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- };
- }
-
- it("starts a NEW turn for a message queued during the turn (the bug fix)", async () => {
- const manager = new AgentManager();
- const processSpy = vi.spyOn(manager, "processMessage");
- setRunImpl(runThatEnqueues(manager, "tab-cont", "follow-up question"));
-
- await manager.processMessage("tab-cont", "first");
- // Let the fire-and-forget continuation turn run to completion.
- await new Promise<void>((r) => setTimeout(r, 50));
-
- // processMessage called twice: the original turn + the continuation.
- expect(processSpy).toHaveBeenCalledTimes(2);
- expect(processSpy.mock.calls[1]?.[0]).toBe("tab-cont");
- expect(processSpy.mock.calls[1]?.[1]).toBe("follow-up question");
-
- // Queue is drained and the tab is idle again.
- const inner = manager as unknown as {
- tabAgents: Map<string, { messageQueue: unknown[] }>;
- };
- expect(inner.tabAgents.get("tab-cont")?.messageQueue).toHaveLength(0);
- expect(manager.getTabStatus("tab-cont")).toBe("idle");
- });
-
- it('emits message-consumed with reason "continuation" when draining between turns', async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
- setRunImpl(runThatEnqueues(manager, "tab-evt", "next"));
-
- await manager.processMessage("tab-evt", "first");
- await new Promise<void>((r) => setTimeout(r, 50));
-
- const consumed = events.find((e) => e.type === "message-consumed") as
- | (AgentEvent & { reason?: string })
- | undefined;
- expect(consumed).toBeDefined();
- expect(consumed?.reason).toBe("continuation");
- });
-
- it("does NOT continue when the queue is empty after a clean turn", async () => {
- const manager = new AgentManager();
- const processSpy = vi.spyOn(manager, "processMessage");
-
- await manager.processMessage("tab-noqueue", "only message");
- await new Promise<void>((r) => setTimeout(r, 30));
-
- expect(processSpy).toHaveBeenCalledTimes(1); // no continuation
- });
-
- it("does NOT continue a turn the user stopped (queue is preserved)", async () => {
- const manager = new AgentManager();
- const processSpy = vi.spyOn(manager, "processMessage");
- // Run that enqueues then aborts itself via stopTab to mimic a user stop.
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- manager.queueMessage("tab-stop", "should wait");
- manager.stopTab("tab-stop");
- yield {
- type: "done",
- message: { role: "assistant", chunks: [] },
- } as const;
- });
-
- await manager.processMessage("tab-stop", "go");
- await new Promise<void>((r) => setTimeout(r, 30));
-
- // Only the original turn ran; the queued message is preserved, unanswered.
- expect(processSpy).toHaveBeenCalledTimes(1);
- const inner = manager as unknown as {
- tabAgents: Map<string, { messageQueue: unknown[] }>;
- };
- expect(inner.tabAgents.get("tab-stop")?.messageQueue).toHaveLength(1);
- });
-
- it("bounds runaway agent<->agent continuation via the auto-wake budget", async () => {
- const manager = new AgentManager();
- // A run that ALWAYS enqueues another message → would loop forever
- // without the budget cap.
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- manager.queueMessage("tab-loop", "again and again");
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "r" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
- const processSpy = vi.spyOn(manager, "processMessage");
-
- await manager.processMessage("tab-loop", "kick off");
- await new Promise<void>((r) => setTimeout(r, 120));
-
- // 1 original + at most MAX_AGENT_AUTO_WAKES (6) continuations = 7.
- // Crucially BOUNDED, not infinite.
- expect(processSpy.mock.calls.length).toBeLessThanOrEqual(7);
- expect(processSpy.mock.calls.length).toBeGreaterThan(1);
- // Budget spent; the last queued message is held, not answered.
- const inner = manager as unknown as {
- tabAgents: Map<string, { autoWakeBudget: number; messageQueue: unknown[] }>;
- };
- expect(inner.tabAgents.get("tab-loop")?.autoWakeBudget).toBe(0);
- expect(inner.tabAgents.get("tab-loop")?.messageQueue.length).toBeGreaterThan(0);
- });
- });
-
- describe("getLastTabResponse", () => {
- it("returns the most recent assistant turn's text and current status", () => {
- const manager = new AgentManager();
- setFakeMessages("tab-hist", [
- makeRow("tab-hist", 1, "user", [{ type: "text", text: "hi" }]),
- makeRow("tab-hist", 2, "assistant", [{ type: "text", text: "first answer" }]),
- makeRow("tab-hist", 3, "user", [{ type: "text", text: "again" }]),
- makeRow("tab-hist", 4, "assistant", [
- { type: "text", text: "second " },
- { type: "text", text: "answer" },
- ]),
- ]);
-
- const res = manager.getLastTabResponse("tab-hist");
- expect(res.text).toBe("second answer");
- expect(res.status).toBe("idle");
- });
-
- it("returns null text when the tab has no assistant turn yet", () => {
- const manager = new AgentManager();
- setFakeMessages("tab-empty", [
- makeRow("tab-empty", 1, "user", [{ type: "text", text: "hi" }]),
- ]);
- const res = manager.getLastTabResponse("tab-empty");
- expect(res.text).toBeNull();
- });
-
- it("skips assistant turns that contain no text chunks", () => {
- const manager = new AgentManager();
- setFakeMessages("tab-toolonly", [
- makeRow("tab-toolonly", 1, "assistant", [{ type: "text", text: "real answer" }]),
- // A later assistant turn with only non-text chunks should be skipped.
- makeRow("tab-toolonly", 2, "assistant", [{ type: "thinking", text: "hmm" }]),
- ]);
- const res = manager.getLastTabResponse("tab-toolonly");
- expect(res.text).toBe("real answer");
- });
- });
-
- describe("send_to_tab / read_tab permission split", () => {
- // Drives the real parent-path tool construction in getOrCreateAgentForTab
- // by toggling the new split permissions and inspecting which tools the
- // constructed Agent received.
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("grants only send_to_tab when only perm_send_to_tab is allowed", async () => {
- const tools = await toolsForPerms("tab-send-only", { perm_send_to_tab: "allow" });
- expect(tools).toContain("send_to_tab");
- expect(tools).not.toContain("read_tab");
- });
-
- it("grants only read_tab when only perm_read_tab is allowed", async () => {
- const tools = await toolsForPerms("tab-read-only", { perm_read_tab: "allow" });
- expect(tools).toContain("read_tab");
- expect(tools).not.toContain("send_to_tab");
- });
-
- it("grants both when both permissions are allowed", async () => {
- const tools = await toolsForPerms("tab-both", {
- perm_send_to_tab: "allow",
- perm_read_tab: "allow",
- });
- expect(tools).toContain("send_to_tab");
- expect(tools).toContain("read_tab");
- });
-
- it("grants neither when both permissions are off", async () => {
- const tools = await toolsForPerms("tab-neither", {});
- expect(tools).not.toContain("send_to_tab");
- expect(tools).not.toContain("read_tab");
- });
- });
-
- describe("search_code permission gating", () => {
- // Reuses the parent-path tool construction to confirm the perm flag wires
- // the search_code tool on/off correctly.
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("grants search_code when perm_search_code is allowed", async () => {
- const tools = await toolsForPerms("tab-cs-on", { perm_search_code: "allow" });
- expect(tools).toContain("search_code");
- });
-
- it("omits search_code when perm_search_code is not allowed", async () => {
- const tools = await toolsForPerms("tab-cs-off", {});
- expect(tools).not.toContain("search_code");
- });
- });
-
- describe("summon / user_agent permission split", () => {
- // Drives the real parent-path tool construction in
- // getOrCreateAgentForTab by toggling perm_summon and perm_user_agent
- // independently, then inspecting which tools the constructed Agent
- // received. The summon tool must be registered when EITHER permission
- // is granted; `retrieve` rides with the subagent permission only
- // (user agents are fire-and-forget).
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("grants summon + retrieve when only perm_summon is allowed", async () => {
- const tools = await toolsForPerms("tab-summon-only", { perm_summon: "allow" });
- expect(tools).toContain("summon");
- expect(tools).toContain("retrieve");
- });
-
- it("grants summon WITHOUT retrieve when only perm_user_agent is allowed", async () => {
- // Regression: granting only the user-agent permission used to leave
- // the agent unable to summon user agents because the whole summon
- // tool was gated behind perm_summon.
- const tools = await toolsForPerms("tab-user-agent-only", { perm_user_agent: "allow" });
- expect(tools).toContain("summon");
- expect(tools).not.toContain("retrieve");
- });
-
- it("grants summon + retrieve when both permissions are allowed", async () => {
- const tools = await toolsForPerms("tab-summon-both", {
- perm_summon: "allow",
- perm_user_agent: "allow",
- });
- expect(tools).toContain("summon");
- expect(tools).toContain("retrieve");
- });
-
- it("grants neither summon nor retrieve when both permissions are off", async () => {
- const tools = await toolsForPerms("tab-summon-neither", {});
- expect(tools).not.toContain("summon");
- expect(tools).not.toContain("retrieve");
- });
- });
-
- describe("key_usage permission gate", () => {
- // The key_usage tool is conditionally useful, so it must be COMPLETELY
- // absent from the toolset (and thus the model's context) unless
- // perm_key_usage is explicitly allowed.
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("registers key_usage when perm_key_usage is allowed", async () => {
- const tools = await toolsForPerms("tab-key-usage-on", { perm_key_usage: "allow" });
- expect(tools).toContain("key_usage");
- });
-
- it("omits key_usage when perm_key_usage is not allowed", async () => {
- const tools = await toolsForPerms("tab-key-usage-off", {});
- expect(tools).not.toContain("key_usage");
- });
- });
-
- // Regression: granted tab-messaging tools must also be ADVERTISED in the
- // agent's system prompt. The tools were registered in the API tool payload
- // but `buildSystemPrompt` filtered its "You have access to the following
- // tools" list through TOOL_DESCRIPTIONS, which lacked send_to_tab/read_tab
- // — so the model was told it didn't have them and refused to use them. This
- // locks the prompt's capability list to the granted toolset.
- describe("send_to_tab / read_tab system-prompt advertisement", () => {
- async function promptForPerms(tabId: string, perms: Record<string, string>): Promise<string> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.systemPrompt ?? "";
- }
-
- it("lists send_to_tab in the system prompt when granted", async () => {
- const prompt = await promptForPerms("tab-prompt-send", { perm_send_to_tab: "allow" });
- expect(prompt).toContain("- send_to_tab:");
- expect(prompt).not.toContain("- read_tab:");
- });
-
- it("lists read_tab in the system prompt when granted", async () => {
- const prompt = await promptForPerms("tab-prompt-read", { perm_read_tab: "allow" });
- expect(prompt).toContain("- read_tab:");
- expect(prompt).not.toContain("- send_to_tab:");
- });
-
- it("lists both tab-messaging tools when both are granted", async () => {
- const prompt = await promptForPerms("tab-prompt-both", {
- perm_send_to_tab: "allow",
- perm_read_tab: "allow",
- });
- expect(prompt).toContain("- send_to_tab:");
- expect(prompt).toContain("- read_tab:");
- });
-
- it("omits both from the system prompt when neither is granted", async () => {
- const prompt = await promptForPerms("tab-prompt-neither", {});
- expect(prompt).not.toContain("- send_to_tab:");
- expect(prompt).not.toContain("- read_tab:");
- });
-
- it("advertises exactly the granted tab tools (prompt list matches schema)", async () => {
- for (const [k, v] of Object.entries({
- perm_send_to_tab: "allow",
- perm_read_tab: "allow",
- })) {
- setFakeSetting(k, v);
- }
- const manager = new AgentManager();
- await manager.processMessage("tab-prompt-match", "go");
- const inst = constructedAgents.at(-1);
- // Every granted tab-messaging tool surfaced in the schema must also be
- // advertised in the prompt, so the model never believes it lacks one.
- for (const name of ["send_to_tab", "read_tab"]) {
- expect(inst?.toolNames).toContain(name);
- expect(inst?.systemPrompt).toContain(`- ${name}:`);
- }
- });
- });
-
- // ─── Usage side-channel persistence ──────────────────────────────
- //
- // `usage` AgentEvents (one per LLM round-trip) are persisted as invisible
- // `type:"usage"` chunk rows so per-tab token/cache telemetry survives a
- // reload. They ride the SAME atomic appendChunks call as the turn's content
- // rows (one fsync, contiguous seqs). A superseded fallback attempt's usage is
- // discarded with its `chunks` (per-attempt accumulator).
- describe("usage persistence", () => {
- it("writes one usage row per usage event emitted during a turn", async () => {
- const manager = new AgentManager();
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- yield {
- type: "usage",
- usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- } as const;
- yield { type: "text-delta", delta: "step two" } as const;
- yield {
- type: "usage",
- usage: {
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- },
- } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "step two" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- await manager.processMessage("tab-usage-rows", "go");
-
- const usageDrafts = appendChunksCalls
- .flatMap((c) => c.drafts)
- .filter((d) => d.type === "usage");
- expect(usageDrafts).toHaveLength(2);
- // One row per event, role=assistant, step cosmetic (0).
- expect(usageDrafts.every((d) => d.role === "assistant" && d.step === 0)).toBe(true);
- expect(usageDrafts[0]?.data).toEqual({
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- });
- expect(usageDrafts[1]?.data).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("attaches the DB usage aggregate to the turn-sealed event for live reconciliation", async () => {
- const manager = new AgentManager();
- const aggregate = {
- inputTokens: 222,
- outputTokens: 22,
- cacheReadTokens: 100,
- cacheWriteTokens: 5,
- requests: 1,
- last: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- };
- fakeUsageStatsByTab.set("tab-sealed-usage", aggregate);
-
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-sealed-usage", "go");
-
- const sealed = events.find((e) => e.type === "turn-sealed") as
- | Extract<AgentEvent, { type: "turn-sealed" }>
- | undefined;
- expect(sealed).toBeDefined();
- // The aggregate read AFTER the write is carried on the event so the
- // frontend can REPLACE its live cacheStats with the DB truth.
- expect(sealed?.usageStats).toEqual(aggregate);
- });
-
- it("emits usage rows in the SAME appendChunks call as the turn's content (one atomic write)", async () => {
- const manager = new AgentManager();
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- yield { type: "text-delta", delta: "hi" } as const;
- yield {
- type: "usage",
- usage: { inputTokens: 5, outputTokens: 1, cacheReadTokens: 2, cacheWriteTokens: 3 },
- } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "hi" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- await manager.processMessage("tab-usage-atomic", "go");
-
- // Exactly one appendChunks call carries the usage draft (the flush). The
- // user-message append and any system-row appends carry no usage rows.
- const callsWithUsage = appendChunksCalls.filter((c) =>
- c.drafts.some((d) => d.type === "usage"),
- );
- expect(callsWithUsage).toHaveLength(1);
- expect(callsWithUsage[0]?.tabId).toBe("tab-usage-atomic");
- });
-
- it("discards a superseded (rate-limited) attempt's usage on fallback", async () => {
- const manager = new AgentManager();
- // Inject a minimal model registry so the rate-limit fallback path is
- // taken (real `processMessage` requires modelRegistry + a resolved
- // keyId + a next fallback entry to retry).
- const markKeyExhausted = vi.fn();
- (
- manager as unknown as {
- modelRegistry: {
- getKeys(): Array<{ definition: Record<string, unknown> }>;
- markKeyExhausted(): void;
- };
- }
- ).modelRegistry = {
- getKeys: () => [
- {
- definition: {
- id: "k1",
- provider: "openai-compatible",
- env: "ENV1",
- base_url: "http://x",
- },
- },
- {
- definition: {
- id: "k2",
- provider: "openai-compatible",
- env: "ENV2",
- base_url: "http://y",
- },
- },
- ],
- markKeyExhausted,
- };
-
- let attempt = 0;
- setRunImpl(async function* () {
- attempt++;
- yield { type: "status", status: "running" } as const;
- if (attempt === 1) {
- // Attempt 1 emits usage then rate-limits — its usage must be dropped.
- yield {
- type: "usage",
- usage: { inputTokens: 999, outputTokens: 9, cacheReadTokens: 0, cacheWriteTokens: 0 },
- } as const;
- yield { type: "error", error: "rate limit exceeded (status=429)" } as const;
- return;
- }
- // Attempt 2 succeeds — only its usage should persist.
- yield {
- type: "usage",
- usage: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "recovered" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- const agentModels = [
- { key_id: "k1", model_id: "m1" },
- { key_id: "k2", model_id: "m2" },
- ];
- await manager.processMessage(
- "tab-usage-fallback",
- "go",
- undefined,
- undefined,
- undefined,
- undefined,
- agentModels,
- );
-
- expect(attempt).toBe(2); // confirm the fallback retry actually happened
- expect(markKeyExhausted).toHaveBeenCalled();
-
- const usageDrafts = appendChunksCalls
- .flatMap((c) => c.drafts)
- .filter((d) => d.type === "usage");
- // Only attempt 2's usage survives.
- expect(usageDrafts).toHaveLength(1);
- expect(usageDrafts[0]?.data).toEqual({
- inputTokens: 222,
- outputTokens: 22,
- cacheReadTokens: 100,
- cacheWriteTokens: 5,
- });
- });
- });
-
- describe("warmCacheForTab (prompt-cache warming)", () => {
- it("returns the warm request usage and forwards the FULL genuine history", async () => {
- const manager = new AgentManager();
- setFakeMessages("tab-warm", [
- makeRow("tab-warm", 1, "user", [{ type: "text", text: "hello" }]),
- makeRow("tab-warm", 2, "assistant", [{ type: "text", text: "hi" }]),
- ]);
-
- const result = await manager.warmCacheForTab("tab-warm");
- expect(result.ok).toBe(true);
- if (result.ok) {
- expect(result.usage).toEqual({
- inputTokens: 1200,
- outputTokens: 1,
- cacheReadTokens: 1100,
- cacheWriteTokens: 0,
- });
- }
- // The genuine history is forwarded UNTRIMMED (both turns), so the
- // replayed prefix matches the next real turn exactly.
- expect(capturedWarmHistories).toHaveLength(1);
- expect(capturedWarmHistories[0]).toHaveLength(2);
- });
-
- it("does NOT persist anything (no appendChunks for the warm request)", async () => {
- const manager = new AgentManager();
- setFakeMessages("tab-warm-2", [
- makeRow("tab-warm-2", 1, "user", [{ type: "text", text: "hello" }]),
- ]);
- await manager.warmCacheForTab("tab-warm-2");
- // Warming must never write chunk rows (history / usage / anything).
- expect(appendChunksCalls).toHaveLength(0);
- });
-
- it("refuses to warm while the tab is generating", async () => {
- const manager = new AgentManager();
- // Start a turn (status flips to running) but don't await it.
- const running = manager.processMessage("tab-warm-busy", "go");
- // Let the mock run() yield its first running status.
- await new Promise<void>((r) => setTimeout(r, 1));
- const result = await manager.warmCacheForTab("tab-warm-busy");
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.error).toBe("tab is generating");
- await running;
- });
- });
-});
-
-describe("AgentManager.compactTab", () => {
- beforeEach(() => {
- resetFakeMessages();
- resetConstructedAgents();
- resetCapturedRunOptions();
- resetFakeTabs();
- resetFakeSettings();
- setRunImpl(null);
- appendEventToChunksSpy.mockClear();
- resetAppendChunksCalls();
- resetFakeUsageStats();
- resetCompactionScaffolding();
- });
-
- /** Seed a usable compaction model (config key + registry key + env key). */
- function seedCompactorModel(keyId = "k1", modelId = "m1"): void {
- fakeConfigKeys.push({ id: keyId, provider: "opencode-go", base_url: "https://x", env: "K1" });
- fakeRegistryKeys.push({ id: keyId, provider: "opencode-go", base_url: "https://x", env: "K1" });
- fakeApiKeys.set(keyId, "secret");
- setFakeSetting("compaction_model_key_id", keyId);
- setFakeSetting("compaction_model_id", modelId);
- }
-
- it("errors when the conversation is too short to compact (empty prompt)", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "Chat" });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- // No prompt → nothing to compact.
- fakeCompactionByTab.set("src", { tail: [], prompt: undefined });
-
- await manager.compactTab("temp", "src");
-
- const err = events.find((e) => e.type === "compaction-error");
- expect(err).toMatchObject({ type: "compaction-error", tempTabId: "temp", sourceTabId: "src" });
- // No backup tab created, no rekey.
- expect(createTabCalls).toHaveLength(0);
- expect(rekeyCalls).toHaveLength(0);
- });
-
- it("errors when no compaction model can be resolved", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "Chat", keyId: null, modelId: null });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- // Compactable, but no configured model and the tab has no key/model.
- // (no seedCompactorModel call)
-
- await manager.compactTab("temp", "src");
-
- expect(events.some((e) => e.type === "compaction-error")).toBe(true);
- expect(rekeyCalls).toHaveLength(0);
- });
-
- it("refuses to compact while the source tab is running", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "Chat" });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- // Start a (never-finishing) turn so the tab is "running".
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 50));
- yield { type: "status", status: "idle" } as const;
- });
- void manager.processMessage("src", "hi");
- // Give processMessage a tick to flip status to running.
- await new Promise<void>((r) => setTimeout(r, 5));
-
- await manager.compactTab("temp", "src");
- const err = events.find((e) => e.type === "compaction-error");
- expect(err).toBeDefined();
- expect(rekeyCalls).toHaveLength(0);
- });
-
- it("happy path: summarizes, relocates history to a backup, re-seeds the source", async () => {
- seedCompactorModel();
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "My Chat", keyId: "k1", modelId: "m1" });
- fakeChunksByTab.set("src", [{ tabId: "src" }, { tabId: "src" }]);
- fakeCompactionByTab.set("src", {
- prompt: "SUMMARY PROMPT",
- tail: [
- { turnId: "tail-u", role: "user", chunks: [{ type: "text", text: "recent q" }] },
- { turnId: "tail-a", role: "assistant", chunks: [{ type: "text", text: "recent a" }] },
- ],
- });
-
- await manager.compactTab("temp", "src");
-
- // started + complete emitted; no error.
- expect(events.some((e) => e.type === "compaction-started")).toBe(true);
- const complete = events.find((e) => e.type === "compaction-complete") as
- | (AgentEvent & { backupTabId: string; backupTitle: string })
- | undefined;
- expect(complete).toBeDefined();
- expect(events.some((e) => e.type === "compaction-error")).toBe(false);
-
- // A backup tab was created and history was relocated onto it.
- expect(createTabCalls).toHaveLength(1);
- expect(rekeyCalls).toHaveLength(1);
- expect(rekeyCalls[0]).toMatchObject({ from: "src", to: createTabCalls[0]?.id });
- expect(complete?.backupTabId).toBe(createTabCalls[0]?.id);
- expect(complete?.backupTitle).toContain("pre-compaction");
-
- // The source was re-seeded: appendChunks was called for "src" after rekey
- // (summary turn + preserved tail).
- const srcAppends = appendChunksCalls.filter((c) => c.tabId === "src");
- expect(srcAppends.length).toBeGreaterThanOrEqual(1);
- });
-
- it("queues messages sent to the source while compaction is in flight", async () => {
- seedCompactorModel();
- const manager = new AgentManager();
- setFakeTab({ id: "src", title: "Chat", keyId: "k1", modelId: "m1" });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- fakeCompactionByTab.set("src", {
- prompt: "SUMMARY PROMPT",
- tail: [{ turnId: "t", role: "user", chunks: [{ type: "text", text: "recent" }] }],
- });
-
- // Make the summary generation slow so we can deliver mid-compaction.
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 40));
- yield { type: "text-delta", delta: "summary text" } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- const compaction = manager.compactTab("temp", "src");
- await new Promise<void>((r) => setTimeout(r, 10));
- // While compacting, a human message should be QUEUED, not started.
- const result = manager.deliverMessage("src", "hello during compaction");
- expect(result.status).toBe("queued");
-
- await compaction;
- });
-});
diff --git a/packages/api/tests/permission-manager.test.ts b/packages/api/tests/permission-manager.test.ts
deleted file mode 100644
index 172adb3..0000000
--- a/packages/api/tests/permission-manager.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// Mock @dispatch/core to provide only the PermissionService impl this test
-// touches — the core barrel transitively pulls in bun:sqlite, which vitest
-// running under Node cannot resolve.
-vi.mock("@dispatch/core", async () => {
- const mod = await import("../../core/src/permission/service.js");
- return {
- PermissionService: mod.PermissionService,
- };
-});
-
-const { PermissionManager } = await import("../src/permission-manager.js");
-
-interface PermissionRequest {
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
- return {
- permission: "bash",
- patterns: ["git *"],
- always: ["git status"],
- description: "Run git status",
- metadata: {},
- ...overrides,
- };
-}
-
-describe("PermissionManager.onPromptAdded", () => {
- it("fires once per newly-added pending prompt", () => {
- const mgr = new PermissionManager();
- const seen: Array<{ id: string; permission: string }> = [];
- mgr.onPromptAdded((p) => {
- seen.push({ id: p.id, permission: p.permission });
- });
-
- void mgr.ask(makeRequest(), []);
- void mgr.ask(makeRequest({ permission: "read", description: "Read X" }), []);
-
- expect(seen).toHaveLength(2);
- expect(seen[0].permission).toBe("bash");
- expect(seen[1].permission).toBe("read");
- // Distinct ids
- expect(seen[0].id).not.toBe(seen[1].id);
- });
-
- it("does not re-fire when the pending list is rebroadcast for an unrelated change", async () => {
- const mgr = new PermissionManager();
- const seen: string[] = [];
- mgr.onPromptAdded((p) => seen.push(p.id));
-
- // Two prompts in; should see two notifications.
- const p1 = mgr.ask(makeRequest(), []);
- void mgr.ask(makeRequest({ permission: "read" }), []);
- expect(seen).toHaveLength(2);
-
- // Resolve the first one — broadcastPending fires again, but the
- // remaining (already-announced) prompt must NOT re-notify.
- const pending = mgr.getPending();
- const firstId = pending[0].id;
- mgr.reply(firstId, "once");
- await p1;
-
- expect(seen).toHaveLength(2);
- });
-
- it("unsubscribe stops further notifications", () => {
- const mgr = new PermissionManager();
- const seen: string[] = [];
- const unsub = mgr.onPromptAdded((p) => seen.push(p.id));
- void mgr.ask(makeRequest(), []);
- unsub();
- void mgr.ask(makeRequest({ permission: "read" }), []);
- expect(seen).toHaveLength(1);
- });
-
- it("listener throws are caught and don't break other listeners", () => {
- const mgr = new PermissionManager();
- const seen: string[] = [];
- mgr.onPromptAdded(() => {
- throw new Error("boom");
- });
- mgr.onPromptAdded((p) => seen.push(p.id));
- // Swallow the warn during this test.
- const origWarn = console.warn;
- console.warn = () => {};
- try {
- void mgr.ask(makeRequest(), []);
- } finally {
- console.warn = origWarn;
- }
- expect(seen).toHaveLength(1);
- });
-});
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
deleted file mode 100644
index 2d5c7c7..0000000
--- a/packages/api/tests/routes.test.ts
+++ /dev/null
@@ -1,1097 +0,0 @@
-import type { ToolDefinition } from "@dispatch/core";
-import { describe, expect, it, vi } from "vitest";
-
-// Seedable backing stores for the tabs route (GET /tabs enrichment). Declared
-// before vi.mock so the hoisted factory closure can reference them; populated
-// per-test.
-interface FakeOpenTab {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-const fakeOpenTabs: FakeOpenTab[] = [];
-const fakeUsageStats = new Map<string, unknown>();
-
-// Mock @dispatch/core's Agent to avoid real LLM calls
-vi.mock("@dispatch/core", () => ({
- Agent: class MockAgent {
- status = "idle";
- messages: unknown[] = [];
- async warmCache(_history: unknown[]) {
- // Simulate a warm replay that read most of the prompt from cache.
- return {
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 900,
- cacheWriteTokens: 0,
- };
- }
- async *run(_message: string) {
- yield { type: "status", status: "running" } as const;
- // Simulate some processing time so status stays "running"
- await new Promise<void>((r) => setTimeout(r, 100));
- yield { type: "text-delta", delta: "Hello " } as const;
- yield { type: "text-delta", delta: "world" } as const;
- yield {
- type: "done",
- message: {
- role: "assistant",
- chunks: [{ type: "text", text: "Hello world" }],
- },
- } as const;
- yield { type: "status", status: "idle" } as const;
- }
- },
- PermissionService: class MockPermissionService {
- ask(_request: unknown, _rulesets: unknown[]) {
- return Promise.resolve("once");
- }
- reply(_id: string, _reply: unknown) {}
- getPending() {
- return [];
- }
- },
- createReadFileTool(_wd: string): ToolDefinition {
- return {
- name: "read_file",
- description: "read a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock file content",
- };
- },
- createReadFileSliceTool(_wd: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description: "read a char slice of a single line",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock slice",
- };
- },
- clearSpillForTab(_tabId: string) {},
- createWriteFileTool(_wd: string): ToolDefinition {
- return {
- name: "write_file",
- description: "write a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => true,
- };
- },
- createListFilesTool(_wd: string): ToolDefinition {
- return {
- name: "list_files",
- description: "list files",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ["file1.ts"],
- };
- },
- createLspTool(_getContext: unknown): ToolDefinition {
- return {
- name: "lsp",
- description: "query the language server",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock lsp",
- };
- },
- LspManager: class MockLspManager {
- hasServerForFile() {
- return false;
- }
- async getClients() {
- return [];
- }
- async touchFile() {}
- getDiagnostics() {
- return {};
- }
- async request() {
- return [];
- }
- async shutdownAll() {}
- },
- resolveServersFromConfig(_lsp: unknown) {
- return [];
- },
- reportDiagnostics(_file: string, _issues: unknown) {
- return "";
- },
- createRunShellTool(_wd: string): ToolDefinition {
- return {
- name: "run_shell",
- description: "run shell command",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
- };
- },
- loadConfig(_dir: string) {
- return { permissions: {} };
- },
- configToRuleset(_config: unknown) {
- return [];
- },
- validateConfig(_config: unknown) {
- return { config: _config, errors: [] };
- },
- createConfigWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- watchDirConfig(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- loadSkills(_dir: string) {
- return { skills: [], mappings: [] };
- },
- createSkillsWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- ModelRegistry: class MockModelRegistry {
- getModels() {
- return [];
- }
- getKeys() {
- return [];
- }
- getModelsByTag(_tag: string) {
- return [];
- }
- getAllTags() {
- return [];
- }
- hasAvailableKey(_provider: string) {
- return false;
- }
- allKeysExhausted() {
- return true;
- }
- markKeyExhausted() {}
- markKeyActive() {}
- updateConfig() {}
- },
- ModelResolver: class MockModelResolver {
- resolve(_tag: string) {
- return null;
- }
- waitForKey() {
- return Promise.resolve(null);
- }
- },
- TaskList: class MockTaskList {
- private tasks: Array<{ id: string; content: string; status: string }> = [];
- getTasks() {
- return this.tasks.map((t) => ({ ...t }));
- }
- setTasks(items: Array<{ content: string; status?: string }>) {
- this.tasks = items.map((item, i) => ({
- id: `task-${i + 1}`,
- content: item.content,
- status: item.status ?? "pending",
- }));
- return this.getTasks();
- }
- onChange(_cb: unknown) {
- return () => {};
- }
- },
- createTaskListTool(_taskList: unknown) {
- return {
- name: "todo",
- description: "todo",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSummonTool(_wd: string, _callbacks: unknown) {
- return {
- name: "summon",
- description: "summon",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createRetrieveTool(_callbacks: unknown) {
- return {
- name: "retrieve",
- description: "retrieve",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createTab() {},
- getTab() {
- return null;
- },
- isReasoningEffort(value: unknown) {
- return (
- typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value)
- );
- },
- // Lightweight stand-in for the real validator: accept the supported media
- // types, reject everything else. Enough to exercise the /chat attachment
- // validation branch (the real validator is unit-tested in core).
- validateUserContent(content: Array<{ type: string; mediaType?: string }>) {
- const accepted = ["image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf"];
- const errors = content
- .filter((p) => p.type === "attachment" && !accepted.includes(p.mediaType ?? ""))
- .map((p) => ({ code: "unsupported-type", mediaType: p.mediaType }));
- return { ok: errors.length === 0, errors };
- },
- listOpenTabs() {
- return [...fakeOpenTabs];
- },
- resolveTabPrefix() {
- return { status: "none" };
- },
- shortestUniquePrefix(id: string) {
- return (id ?? "").slice(0, 4);
- },
- createSendToTabTool(_callbacks: unknown) {
- return {
- name: "send_to_tab",
- description: "send to tab",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createReadTabTool(_callbacks: unknown) {
- return {
- name: "read_tab",
- description: "read tab",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- getClaudeAccountsFromDB() {
- return [];
- },
- refreshAccountCredentials() {
- return null;
- },
- refreshAccountCredentialsAsync() {
- return Promise.resolve(null);
- },
- resolveApiKey() {
- return null;
- },
- getSetting(_key: string) {
- return null;
- },
- setSetting(_key: string, _value: string) {},
- deleteSetting(_key: string) {},
- appendChunks() {
- return [];
- },
- explodeUserText() {
- return [];
- },
- explodeTurn() {
- return [];
- },
- getMessagesForTab() {
- return [];
- },
- getChunksForTab() {
- return [];
- },
- groupRowsToMessages() {
- return [];
- },
- getTotalChunkCount() {
- return 0;
- },
- getUsageStatsForTab(tabId: string) {
- return fakeUsageStats.get(tabId) ?? null;
- },
- appendEventToChunks(_chunks: unknown[], _event: unknown) {
- // no-op stub
- },
- applySystemEvent(_messages: unknown[], _event: unknown) {
- return { messageId: "mock-system-msg" };
- },
- BackgroundShellStore: class MockBackgroundShellStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- BackgroundTranscriptStore: class MockBackgroundTranscriptStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- createWebSearchTool() {
- return {
- name: "web_search",
- description: "web search",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSearchCodeTool(_wd: string) {
- return {
- name: "search_code",
- description: "search code",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createYoutubeTranscribeTool() {
- return {
- name: "youtube_transcribe",
- description: "youtube transcribe",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- // ── models.dev context-limit stub ─────────────────────────────
- resolveContextLimit(provider: string, modelId: string) {
- if (provider === "anthropic" && modelId === "claude-sonnet-4-5") {
- return Promise.resolve(200000);
- }
- return Promise.resolve(null);
- },
- // ── ntfy notifications stubs ──────────────────────────────────
- NotificationDispatcher: class MockNotificationDispatcher {
- attachToAgentManager() {
- return () => {};
- }
- attachToPermissionManager() {
- return () => {};
- }
- notify() {}
- dispose() {}
- },
- loadNtfyConfig() {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
- };
- },
- saveNtfyConfig() {},
- normalizeNtfyConfig(c: unknown) {
- return c;
- },
- defaultNtfyConfig() {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
- };
- },
- redactNtfyConfig(c: { authToken?: string }) {
- return { ...c, authToken: "", hasAuthToken: false };
- },
- NTFY_EVENT_TYPES: ["turn-completed", "turn-error", "permission-required", "agent-spawned"],
- async sendNtfy() {
- return { ok: true };
- },
-}));
-
-const { app } = await import("../src/app.js");
-
-describe("GET /health", () => {
- it("returns 200 with ok: true", async () => {
- const res = await app.request("/health");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ ok: true });
- });
-});
-
-describe("GET /status", () => {
- it("returns idle status initially", async () => {
- const res = await app.request("/status");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.status).toBe("idle");
- expect(typeof body.messageCount).toBe("number");
- });
-});
-
-describe("POST /chat", () => {
- it("returns 200 with valid message", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1", message: "hello world" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ status: "ok" });
- });
-
- it("accepts xhigh as a valid reasoningEffort", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-xhigh",
- message: "hello",
- reasoningEffort: "xhigh",
- }),
- });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ status: "ok" });
- });
-
- it("tolerates an invalid agentModels effort (sanitized, not rejected)", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-badeffort",
- message: "hello",
- agentModels: [{ key_id: "k", model_id: "m", effort: "turbo" }],
- }),
- });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ status: "ok" });
- });
-
- it("accepts a valid image attachment and starts a turn", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-img-ok",
- message: "look: [image]",
- content: [
- { type: "text", text: "look: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- }),
- });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ status: "ok" });
- });
-
- it("returns 400 for an unsupported attachment media type", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-img-bad",
- message: "look: [image]",
- content: [{ type: "attachment", mediaType: "image/svg+xml", data: "QQ==" }],
- }),
- });
- expect(res.status).toBe(400);
- const body = await res.json();
- expect(body.error).toBe("invalid attachments");
- });
-
- it("returns 409 when attaching while the agent is generating", async () => {
- // Kick off a turn so the tab is running.
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-img-busy", message: "first" }),
- });
- await new Promise<void>((r) => setTimeout(r, 20));
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-img-busy",
- message: "second [image]",
- content: [{ type: "attachment", mediaType: "image/png", data: "QQ==" }],
- }),
- });
- expect(res.status).toBe(409);
- });
-
- it("returns 400 with empty message", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1", message: "" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 with whitespace-only message", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1", message: " " }),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 with missing message field", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 with missing tabId", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hello" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("queues message when agent is already running", async () => {
- // Start a message (non-blocking)
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-2", message: "first message" }),
- });
-
- // Small delay to let the async generator start and emit "running" status
- await new Promise<void>((r) => setTimeout(r, 20));
-
- // Send a second — agent should queue it
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-2", message: "second message" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.status).toBe("queued");
- expect(typeof body.messageId).toBe("string");
- });
-});
-
-describe("GET /tabs", () => {
- it("enriches each open tab with its persisted usageStats aggregate", async () => {
- fakeOpenTabs.length = 0;
- fakeUsageStats.clear();
- fakeOpenTabs.push({
- id: "tab-u",
- title: "Has usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- status: "idle",
- isOpen: true,
- position: 0,
- createdAt: 0,
- updatedAt: 0,
- });
- fakeOpenTabs.push({
- id: "tab-none",
- title: "No usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- status: "idle",
- isOpen: true,
- position: 1,
- createdAt: 0,
- updatedAt: 0,
- });
- fakeUsageStats.set("tab-u", {
- inputTokens: 2200,
- outputTokens: 100,
- cacheReadTokens: 1000,
- cacheWriteTokens: 1000,
- requests: 2,
- last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- });
-
- const res = await app.request("/tabs");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(Array.isArray(body.tabs)).toBe(true);
- const tabU = body.tabs.find((t: { id: string }) => t.id === "tab-u");
- const tabNone = body.tabs.find((t: { id: string }) => t.id === "tab-none");
- expect(tabU.usageStats).toEqual({
- inputTokens: 2200,
- outputTokens: 100,
- cacheReadTokens: 1000,
- cacheWriteTokens: 1000,
- requests: 2,
- last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- });
- // A tab with no usage rows surfaces null (not undefined/missing).
- expect(tabNone.usageStats).toBeNull();
-
- fakeOpenTabs.length = 0;
- fakeUsageStats.clear();
- });
-});
-
-describe("GET /tabs/:id/chunks", () => {
- it("returns the raw chunk window shape { chunks, total, oldestSeq }", async () => {
- const res = await app.request("/tabs/tab-x/chunks?limit=50");
- expect(res.status).toBe(200);
- const body = await res.json();
- // Mocked getChunksForTab returns [] → empty window, null cursor.
- expect(Array.isArray(body.chunks)).toBe(true);
- expect(body.chunks).toEqual([]);
- expect(body.total).toBe(0);
- expect(body.oldestSeq).toBeNull();
- });
-});
-
-describe("POST /tabs/:id/compact", () => {
- it("returns 400 when sourceTabId is missing", async () => {
- const res = await app.request("/tabs/temp-1/compact", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 202 and kicks off compaction when sourceTabId is provided", async () => {
- const res = await app.request("/tabs/temp-1/compact", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ sourceTabId: "src-1" }),
- });
- expect(res.status).toBe(202);
- const body = await res.json();
- expect(body).toEqual({ success: true });
- });
-});
-
-describe("GET/PUT /tabs/settings/compaction-model", () => {
- it("GET returns the persisted compaction-model setting shape", async () => {
- const res = await app.request("/tabs/settings/compaction-model");
- expect(res.status).toBe(200);
- const body = await res.json();
- // Mocked getSetting → null, so both fields are null.
- expect(body).toEqual({ keyId: null, modelId: null });
- });
-
- it("PUT accepts a key/model pair", async () => {
- const res = await app.request("/tabs/settings/compaction-model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: "k1", modelId: "m1" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ success: true });
- });
-});
-
-describe("POST /chat/stop", () => {
- it("returns 200 with success: true for valid tabId", async () => {
- const res = await app.request("/chat/stop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-stop-1" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ success: true });
- });
-
- it("returns 400 when tabId is missing", async () => {
- const res = await app.request("/chat/stop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-});
-
-describe("POST /chat/warm", () => {
- it("returns ONLY the warming request usage (never persisted/emitted)", async () => {
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-warm-1" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { usage?: Record<string, number> };
- expect(body.usage).toEqual({
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 900,
- cacheWriteTokens: 0,
- });
- });
-
- it("returns 400 when tabId is missing", async () => {
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 409 while the tab is generating", async () => {
- // Kick off a real (mock) turn so the tab is "running", then immediately
- // attempt to warm it — warming must refuse mid-turn.
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-warm-busy", message: "hi" }),
- });
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-warm-busy" }),
- });
- expect(res.status).toBe(409);
- });
-});
-
-describe("Wake schedule routes", () => {
- async function getSchedule() {
- const res = await app.request("/models/wake-schedule");
- expect(res.status).toBe(200);
- return (await res.json()) as {
- schedule: Record<string, Record<string, number>>;
- resetOffsetHours: number;
- probeSlotMinutes: number[];
- lastWake: unknown;
- pendingRetry: unknown;
- };
- }
-
- /**
- * Auto-derives `action` from `timestamps` presence:
- * - body has `timestamps` → action = "on"
- * - body has no `timestamps` → action = "off"
- * Tests can override by passing `action` explicitly. This keeps the
- * intent-vs-state contract enforced (every request carries an explicit
- * action) while keeping the existing test bodies short.
- */
- async function toggle(body: Record<string, unknown>) {
- const withAction: Record<string, unknown> =
- "action" in body ? body : { ...body, action: body.timestamps !== undefined ? "on" : "off" };
- return app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(withAction),
- });
- }
-
- /** Build a `timestamps` payload with all 4 probe slots set to absolute ms. */
- function buildTimestamps(base: number): Record<string, number> {
- return { "0": base, "15": base + 60_000, "30": base + 120_000, "45": base + 180_000 };
- }
-
- it("GET returns the full snapshot shape with probeSlotMinutes, resetOffsetHours, lastWake, pendingRetry", async () => {
- const snap = await getSchedule();
- expect(snap.schedule).toBeDefined();
- expect(Number.isInteger(snap.resetOffsetHours)).toBe(true);
- expect(snap.resetOffsetHours).toBeGreaterThan(0);
- expect(snap.probeSlotMinutes).toEqual([0, 15, 30, 45]);
- expect(snap.lastWake).toBeNull();
- expect(snap.pendingRetry).toBeNull();
- });
-
- it("POST toggle adds an hour as 4 probe slots and removes them all together", async () => {
- const base = Date.now() + 60 * 60 * 1000; // 1 h ahead
- const timestamps = buildTimestamps(base);
-
- const addRes = await toggle({ hour: 9, timestamps });
- expect(addRes.status).toBe(200);
- const addBody = (await addRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- expect(addBody.schedule["9"]).toEqual({
- "0": base,
- "15": base + 60_000,
- "30": base + 120_000,
- "45": base + 180_000,
- });
-
- const removeRes = await toggle({ hour: 9 });
- expect(removeRes.status).toBe(200);
- const removeBody = (await removeRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- expect(removeBody.schedule["9"]).toBeUndefined();
- });
-
- it("POST toggle rejects out-of-range hour", async () => {
- const res = await toggle({
- hour: 24,
- timestamps: buildTimestamps(Date.now() + 60_000),
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects negative hour", async () => {
- const res = await toggle({
- hour: -1,
- timestamps: buildTimestamps(Date.now() + 60_000),
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects non-integer hour", async () => {
- const res = await toggle({
- hour: 4.5,
- timestamps: buildTimestamps(Date.now() + 60_000),
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle ACCEPTS a slightly-past timestamp (clock skew / latency)", async () => {
- // Regression guard for Gemini-review finding #1: the old code rejected
- // any slot timestamp <= server now, which broke legitimate toggles when
- // network latency made an imminent slot land "in the past". The HTTP
- // layer must accept it; the scheduler then either fires it immediately
- // (if within MISSED_WAKE_GRACE_MS) or rolls it forward by 24h × N. By
- // the time the response returns, the scheduler tick has already run
- // synchronously up to its first await — so the snapshot reflects the
- // post-advance ts (strictly > now), not the original past ts.
- await toggle({ hour: 22 }); // ensure clean
- const now = Date.now();
- const timestamps: Record<string, number> = {
- "0": now - 5_000, // 5s in the past — well within any plausible skew
- "15": now + 60_000,
- "30": now + 120_000,
- "45": now + 180_000,
- };
- const res = await toggle({ hour: 22, timestamps });
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- const slot0 = body.schedule["22"]?.["0"];
- expect(typeof slot0).toBe("number");
- expect((slot0 ?? 0) > now).toBe(true); // slot :00 was advanced by the tick
- expect(body.schedule["22"]?.["15"]).toBe(now + 60_000); // future slot kept
- await toggle({ hour: 22 }); // cleanup
- });
-
- it("POST toggle rejects NaN / Infinity / non-number slot values", async () => {
- // Use a dedicated hour and ALWAYS clean it up, even on assertion failure,
- // so we don't leak state into the next iteration (which would otherwise
- // interpret the next toggle as a DELETE and return 200).
- const hour = 23;
- await toggle({ hour }); // ensure clean
- for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, "x", null]) {
- const now = Date.now();
- const timestamps: Record<string, unknown> = {
- "0": now + 60_000,
- "15": bad,
- "30": now + 180_000,
- "45": now + 240_000,
- };
- const res = await toggle({ hour, timestamps });
- try {
- expect(res.status, `bad value: ${String(bad)}`).toBe(400);
- } finally {
- await toggle({ hour }); // ensure clean for next iteration
- }
- }
- });
-
- it("POST toggle rejects action='on' with missing timestamps", async () => {
- // Under the explicit-action contract, the helper would otherwise
- // auto-derive { action: 'off' } for a body with no timestamps. Pass
- // action explicitly so we exercise the on-without-timestamps reject.
- const res = await toggle({ hour: 8, action: "on" });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects missing slot in timestamps object", async () => {
- const res = await toggle({
- hour: 8,
- timestamps: { "0": Date.now() + 60_000, "15": Date.now() + 120_000 },
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects non-object timestamps", async () => {
- const res = await toggle({ hour: 8, timestamps: 12345 });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle: a delete does NOT require timestamps", async () => {
- const base = Date.now() + 60 * 60 * 1000;
- const addRes = await toggle({ hour: 11, timestamps: buildTimestamps(base) });
- expect(addRes.status).toBe(200);
- const delRes = await toggle({ hour: 11 });
- expect(delRes.status).toBe(200);
- const body = (await delRes.json()) as { schedule: Record<string, unknown> };
- expect(body.schedule["11"]).toBeUndefined();
- });
-
- it("snapshot reflects multiple marked hours independently with all 4 slots each", async () => {
- const base1 = Date.now() + 2 * 60 * 60 * 1000;
- const base2 = base1 + 60 * 60 * 1000;
- await toggle({ hour: 14, timestamps: buildTimestamps(base1) });
- await toggle({ hour: 19, timestamps: buildTimestamps(base2) });
- const snap = await getSchedule();
- expect(Object.keys(snap.schedule["14"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
- expect(Object.keys(snap.schedule["19"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
- expect(snap.schedule["14"]?.["0"]).toBe(base1);
- expect(snap.schedule["19"]?.["0"]).toBe(base2);
- // Cleanup so later tests start clean.
- await toggle({ hour: 14 });
- await toggle({ hour: 19 });
- });
-
- it("re-toggling the same hour replaces all 4 slot timestamps", async () => {
- const base1 = Date.now() + 60 * 60 * 1000;
- const base2 = base1 + 30 * 60 * 1000;
- await toggle({ hour: 5, timestamps: buildTimestamps(base1) });
- await toggle({ hour: 5 }); // remove
- const addRes = await toggle({ hour: 5, timestamps: buildTimestamps(base2) });
- const body = (await addRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- expect(body.schedule["5"]?.["0"]).toBe(base2);
- expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000);
- await toggle({ hour: 5 });
- });
-
- it("snapshot remains consistent across toggle round-trips (persistSchedule atomicity)", async () => {
- // Regression guard for Gemini-review finding #3: persistSchedule
- // originally did DELETE + N INSERTs without a transaction. A mid-loop
- // failure would commit the DELETE and lose the schedule. We can't
- // directly induce a SQLite mid-INSERT failure from here without
- // monkey-patching getDatabase, but we CAN assert that the steady-state
- // round-trip never drops rows — and a transactional impl must agree
- // with itself across GET/POST cycles.
- const base = Date.now() + 60 * 60 * 1000;
- await toggle({ hour: 1, timestamps: buildTimestamps(base) });
- await toggle({ hour: 2, timestamps: buildTimestamps(base + 60_000) });
- await toggle({ hour: 3, timestamps: buildTimestamps(base + 120_000) });
- const snap = await getSchedule();
- for (const h of ["1", "2", "3"]) {
- expect(Object.keys(snap.schedule[h] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
- }
- // Remove one; the others must be untouched.
- await toggle({ hour: 2 });
- const snap2 = await getSchedule();
- expect(snap2.schedule["1"]).toBeDefined();
- expect(snap2.schedule["2"]).toBeUndefined();
- expect(snap2.schedule["3"]).toBeDefined();
- // Cleanup.
- await toggle({ hour: 1 });
- await toggle({ hour: 3 });
- });
-
- it("POST toggle requires explicit action: 'on' | 'off' (Gemini round-2 #2)", async () => {
- // The server must reject a request that omits `action`. This is the
- // contract that closes the desync-causes-inverted-clicks failure mode:
- // the server is no longer allowed to guess the user's intent from its
- // own (possibly stale-relative-to-UI) in-memory state.
- const now = Date.now();
- const raw = await app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour: 6, timestamps: buildTimestamps(now + 60_000) }),
- });
- expect(raw.status).toBe(400);
-
- for (const bad of ["toggle", "ON", "OFF", "", true, 1, null]) {
- const res = await app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour: 6, action: bad, timestamps: buildTimestamps(now + 60_000) }),
- });
- expect(res.status, `bad action: ${JSON.stringify(bad)}`).toBe(400);
- }
- });
-
- it("POST toggle action='off' is idempotent on an already-off hour (no error)", async () => {
- // Stale UI scenario: the UI thinks hour 4 is on and clicks to turn it
- // off, but server state had already deleted it. Must succeed quietly,
- // not 400 or change anything else.
- const before = await getSchedule();
- expect(before.schedule["4"]).toBeUndefined();
- const res = await toggle({ hour: 4 }); // helper auto-derives action='off'
- expect(res.status).toBe(200);
- const body = (await res.json()) as { schedule: Record<string, unknown> };
- expect(body.schedule["4"]).toBeUndefined();
- });
-
- it("POST toggle action='on' on an already-on hour REPLACES timestamps (recovery from desync)", async () => {
- // Stale UI scenario: the UI thinks hour 12 is off and clicks to turn
- // it on, but server state had it already on (from a snapshot the UI
- // missed). Old behavior would have INVERTED the click (turning it
- // off); new behavior replaces the timestamps with the user's freshly
- // computed wall-clock intent and keeps the hour on.
- const base1 = Date.now() + 60 * 60 * 1000;
- const base2 = base1 + 7 * 60 * 60 * 1000;
- const addRes = await toggle({ hour: 12, timestamps: buildTimestamps(base1) });
- expect(addRes.status).toBe(200);
- const reAddRes = await toggle({ hour: 12, timestamps: buildTimestamps(base2) });
- expect(reAddRes.status).toBe(200);
- const body = (await reAddRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- // Hour still present (NOT inverted to off), AND timestamps refreshed.
- expect(body.schedule["12"]?.["0"]).toBe(base2);
- expect(body.schedule["12"]?.["45"]).toBe(base2 + 180_000);
- await toggle({ hour: 12 }); // cleanup
- });
-
- it("POST toggle action='off' ignores timestamps payload (off doesn't need them)", async () => {
- // Accepting extra fields on an off request is fine; we only fail if
- // an action='on' lacks timestamps.
- const base = Date.now() + 60 * 60 * 1000;
- await toggle({ hour: 13, timestamps: buildTimestamps(base) });
- const res = await app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour: 13, action: "off", timestamps: buildTimestamps(base) }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { schedule: Record<string, unknown> };
- expect(body.schedule["13"]).toBeUndefined();
- });
-});
-
-describe("GET /models/context-limit", () => {
- it("returns the resolved context limit for a known model", async () => {
- const res = await app.request(
- "/models/context-limit?provider=anthropic&modelId=claude-sonnet-4-5",
- );
- expect(res.status).toBe(200);
- const body = (await res.json()) as { contextLimit: number | null };
- expect(body.contextLimit).toBe(200000);
- });
-
- it("returns null contextLimit for an unknown model", async () => {
- const res = await app.request("/models/context-limit?provider=anthropic&modelId=mystery");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { contextLimit: number | null };
- expect(body.contextLimit).toBeNull();
- });
-
- it("400s when provider or modelId is missing", async () => {
- const res1 = await app.request("/models/context-limit?provider=anthropic");
- expect(res1.status).toBe(400);
- const res2 = await app.request("/models/context-limit?modelId=claude-sonnet-4-5");
- expect(res2.status).toBe(400);
- });
-});
diff --git a/packages/api/tests/wake-scheduler.test.ts b/packages/api/tests/wake-scheduler.test.ts
deleted file mode 100644
index 0e5731c..0000000
--- a/packages/api/tests/wake-scheduler.test.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- CLAUDE_RESET_OFFSET_HOURS,
- DAILY_INTERVAL_MS,
- MISSED_WAKE_GRACE_MS,
- nextDailyAfter,
- recoverScheduleEntry,
- resetHourFor,
-} from "../src/wake-scheduler.js";
-
-const HOUR = 60 * 60 * 1000;
-
-describe("nextDailyAfter", () => {
- it("returns the input when it is already strictly in the future", () => {
- const now = 1_700_000_000_000;
- const future = now + 60_000;
- expect(nextDailyAfter(future, now)).toBe(future);
- });
-
- it("advances by exactly 24h when the input is 1ms in the past", () => {
- const now = 1_700_000_000_000;
- const previous = now - 1;
- expect(nextDailyAfter(previous, now)).toBe(previous + DAILY_INTERVAL_MS);
- });
-
- it("skips multiple missed days in a single step when far in the past", () => {
- const now = 1_700_000_000_000;
- const previous = now - 3 * DAILY_INTERVAL_MS - HOUR; // 3d 1h ago
- const next = nextDailyAfter(previous, now);
- expect(next).toBeGreaterThan(now);
- // Should be the next 24h-multiple boundary, not just +24h.
- expect((next - previous) % DAILY_INTERVAL_MS).toBe(0);
- expect(next - previous).toBe(4 * DAILY_INTERVAL_MS);
- });
-
- it("returns previous + 1 day exactly when previous == now", () => {
- const now = 1_700_000_000_000;
- expect(nextDailyAfter(now, now)).toBe(now + DAILY_INTERVAL_MS);
- });
-});
-
-describe("recoverScheduleEntry", () => {
- const now = 1_700_000_000_000;
-
- it("leaves a future entry unchanged and does not fire", () => {
- const stored = now + 5 * HOUR;
- expect(recoverScheduleEntry(stored, now)).toEqual({
- nextWakeAt: stored,
- shouldFireNow: false,
- });
- });
-
- it("fires now for an entry missed by less than the grace window", () => {
- const stored = now - HOUR; // 1h ago, within 2h grace
- const recovered = recoverScheduleEntry(stored, now);
- expect(recovered.shouldFireNow).toBe(true);
- expect(recovered.nextWakeAt).toBeGreaterThan(now);
- });
-
- it("fires now for an entry missed by exactly the grace window", () => {
- const stored = now - MISSED_WAKE_GRACE_MS;
- expect(recoverScheduleEntry(stored, now).shouldFireNow).toBe(true);
- });
-
- it("does NOT fire for an entry missed by more than the grace window", () => {
- const stored = now - MISSED_WAKE_GRACE_MS - 1;
- const recovered = recoverScheduleEntry(stored, now);
- expect(recovered.shouldFireNow).toBe(false);
- expect(recovered.nextWakeAt).toBeGreaterThan(now);
- });
-
- it("always returns a future nextWakeAt for past entries (regardless of grace)", () => {
- for (const ageDays of [0.1, 0.5, 1, 2, 7, 30]) {
- const stored = now - ageDays * DAILY_INTERVAL_MS;
- const { nextWakeAt } = recoverScheduleEntry(stored, now);
- expect(nextWakeAt, `age=${ageDays}d`).toBeGreaterThan(now);
- }
- });
-
- it("respects a custom grace window", () => {
- const stored = now - 10 * 60_000; // 10 min ago
- expect(recoverScheduleEntry(stored, now, 5 * 60_000).shouldFireNow).toBe(false);
- expect(recoverScheduleEntry(stored, now, 15 * 60_000).shouldFireNow).toBe(true);
- });
-});
-
-describe("resetHourFor / CLAUDE_RESET_OFFSET_HOURS", () => {
- it("adds the offset modulo 24", () => {
- expect(resetHourFor(0)).toBe(CLAUDE_RESET_OFFSET_HOURS);
- expect(resetHourFor(20)).toBe((20 + CLAUDE_RESET_OFFSET_HOURS) % 24);
- expect(resetHourFor(23)).toBe((23 + CLAUDE_RESET_OFFSET_HOURS) % 24);
- });
-
- it("wraps cleanly across midnight", () => {
- // Wake at 22:15, +5h = 03:00
- expect(resetHourFor(22)).toBe(3);
- });
-});
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
deleted file mode 100644
index 2d6fedd..0000000
--- a/packages/api/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "types": ["@types/bun"]
- },
- "include": ["src/**/*.ts"],
- "exclude": ["node_modules", "dist", "tests"]
-}
diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts
deleted file mode 100644
index 854422e..0000000
--- a/packages/api/vitest.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- include: ["tests/**/*.test.ts"],
- server: {
- deps: {
- inline: ["zod", "@dispatch/core"],
- },
- },
- },
-});
diff --git a/packages/core/package.json b/packages/core/package.json
deleted file mode 100644
index 55dff0f..0000000
--- a/packages/core/package.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "name": "@dispatch/core",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "src/index.ts",
- "types": "src/index.ts",
- "scripts": {
- "test": "vitest run",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit"
- },
- "dependencies": {
- "@ai-sdk/anthropic": "^3.0.79",
- "@ai-sdk/openai-compatible": "^2.0.48",
- "ai": "^6.0.191",
- "chokidar": "^5.0.0",
- "smol-toml": "^1.6.1",
- "tree-sitter-bash": "^0.25.1",
- "vscode-jsonrpc": "8.2.1",
- "vscode-languageserver-types": "3.17.5",
- "web-tree-sitter": "^0.26.8",
- "zod": "^3.23.0",
- "zod-to-json-schema": "^3.25.2"
- },
- "devDependencies": {
- "@types/bun": "latest"
- }
-}
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
deleted file mode 100644
index 2e2dbb2..0000000
--- a/packages/core/src/agent/agent.ts
+++ /dev/null
Binary files differ
diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts
deleted file mode 100644
index 4931162..0000000
--- a/packages/core/src/agents/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export {
- deleteAgent,
- expandAgentToolNames,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getAgentDirs,
- getProjectAgentsDir,
- loadAgent,
- loadAgents,
- saveAgent,
-} from "./loader.js";
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
deleted file mode 100644
index 9971803..0000000
--- a/packages/core/src/agents/loader.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
-import { type AgentDefinition, type AgentModelEntry, isReasoningEffort } from "../types/index.js";
-
-// ─── Helpers ─────────────────────────────────────────────────────
-
-/** Sanitize a slug to prevent path traversal */
-function sanitizeSlug(slug: string): string {
- // Strip directory components and ensure only safe characters
- const base = path.basename(slug);
- const clean = base
- .replace(/[^a-zA-Z0-9_-]/g, "-")
- .replace(/-+/g, "-")
- .replace(/^-|-$/g, "");
- if (!clean) throw new Error("Invalid agent slug");
- return clean;
-}
-
-// ─── Constants ───────────────────────────────────────────────────
-
-export const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents");
-
-export function getProjectAgentsDir(projectDir: string): string {
- return path.join(projectDir, ".dispatch", "agents");
-}
-
-// ─── Public API ──────────────────────────────────────────────────
-
-/**
- * Returns the agent directories that exist or could exist.
- * Always includes global. Includes project dir if projectDir is provided.
- */
-export function getAgentDirs(
- projectDir?: string,
-): Array<{ label: string; path: string; scope: string }> {
- const dirs: Array<{ label: string; path: string; scope: string }> = [
- { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" },
- ];
- if (projectDir) {
- dirs.push({
- label: `.dispatch/agents (${path.basename(projectDir)})`,
- path: getProjectAgentsDir(projectDir),
- scope: projectDir,
- });
- }
- return dirs;
-}
-
-/**
- * Return just the absolute filesystem paths of the agent directories.
- * Used by the agent's permission gate to grant read-only access to
- * these locations by default (so any agent can list/read agent
- * definitions without prompting the user).
- */
-export function getAgentDirPaths(projectDir?: string): string[] {
- const paths = [GLOBAL_AGENTS_DIR];
- if (projectDir) paths.push(getProjectAgentsDir(projectDir));
- return paths;
-}
-
-/**
- * Load a single agent definition by slug. Searches the project-scoped
- * directory first (if `projectDir` is provided), then falls back to
- * the global directory. Returns `null` if no match is found.
- *
- * Slug matching is exact and case-sensitive; sanitization mirrors
- * `saveAgent` to keep loader and writer symmetric.
- */
-export function loadAgent(slug: string, projectDir?: string): AgentDefinition | null {
- const safeSlug = sanitizeSlug(slug);
- const agents = loadAgents(projectDir);
- return agents.find((a) => a.slug === safeSlug) ?? null;
-}
-
-/**
- * Translate the short permission-group names used by `AgentDefinition.tools`
- * (e.g. `"read"`, `"edit"`, `"bash"`) into the concrete tool-implementation
- * names registered with the agent runtime (e.g. `"read_file"`,
- * `"list_files"`, `"write_file"`, `"run_shell"`).
- *
- * The mapping mirrors the per-permission tool-creation paths in
- * `AgentManager.getOrCreateAgentForTab` so a subagent summoned with a
- * given agent definition ends up with the exact same set of registered
- * tools as a top-level tab using that definition. Tool names that aren't
- * group aliases (`summon`, `retrieve`, `web_search`, `youtube_transcribe`,
- * `todo`) are passed through unchanged.
- *
- * `"todo"` is auto-included so the summoned agent always has its task list
- * available, matching the parent-agent path which always registers `todo`.
- */
-export function expandAgentToolNames(tools: string[]): string[] {
- const expanded = new Set<string>();
- for (const t of tools) {
- switch (t) {
- case "read":
- expanded.add("read_file");
- expanded.add("read_file_slice");
- expanded.add("list_files");
- break;
- case "edit":
- expanded.add("write_file");
- break;
- case "bash":
- expanded.add("run_shell");
- break;
- default:
- // Pass through tool names that aren't permission-group
- // aliases (summon, retrieve, web_search, youtube_transcribe,
- // send_to_tab, read_tab, todo, and the granular file tools
- // themselves if a user hand-wrote them in a TOML).
- expanded.add(t);
- }
- }
- // Always include `todo` — every agent should be able to track its work,
- // and the parent-agent path adds it unconditionally.
- expanded.add("todo");
- return Array.from(expanded);
-}
-
-/**
- * Ensure the default global agent exists. Creates it if missing.
- */
-function ensureDefaultAgent(): void {
- const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml");
- if (fs.existsSync(filePath)) return;
-
- const defaultAgent: AgentDefinition = {
- name: "Default",
- description: "Default agent with all tools enabled",
- skills: [],
- tools: ["read", "edit", "bash", "summon"],
- models: [],
- scope: "global",
- slug: "default",
- };
- saveAgent(defaultAgent);
-}
-
-/**
- * Load all agent definitions from global + project directories.
- * Auto-generates the default global agent if it doesn't exist.
- */
-export function loadAgents(projectDir?: string): AgentDefinition[] {
- ensureDefaultAgent();
-
- const agents: AgentDefinition[] = [];
-
- // Global agents
- agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global"));
-
- // Project-scoped agents
- if (projectDir) {
- agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir));
- }
-
- return agents;
-}
-
-/**
- * Save (create or update) an agent definition to a TOML file.
- * The scope determines which directory:
- * - "global" -> ~/.config/dispatch/agents/
- * - any other string -> that directory path + /.dispatch/agents/
- */
-export function saveAgent(agent: AgentDefinition): void {
- if (agent.scope !== "global" && agent.scope.includes("..")) {
- throw new Error("Invalid agent scope");
- }
- const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope);
-
- fs.mkdirSync(dir, { recursive: true });
-
- const tomlContent: Record<string, unknown> = {
- name: agent.name,
- description: agent.description,
- skills: agent.skills,
- tools: agent.tools,
- };
-
- if (agent.cwd) {
- tomlContent.cwd = agent.cwd;
- }
-
- if (agent.is_subagent) {
- tomlContent.is_subagent = true;
- }
-
- // smol-toml handles [[models]] array-of-tables
- if (agent.models.length > 0) {
- tomlContent.models = agent.models.map((m) => ({
- key_id: m.key_id,
- model_id: m.model_id,
- ...(m.effort ? { effort: m.effort } : {}),
- }));
- }
-
- const content = stringifyTOML(tomlContent);
- const safeSlug = sanitizeSlug(agent.slug);
- const filePath = path.join(dir, `${safeSlug}.toml`);
- fs.writeFileSync(filePath, content, "utf-8");
-}
-
-/**
- * Delete an agent TOML file.
- */
-export function deleteAgent(slug: string, scope: string): boolean {
- if (scope !== "global" && scope.includes("..")) {
- throw new Error("Invalid agent scope");
- }
- const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope);
-
- const safeSlug = sanitizeSlug(slug);
- const filePath = path.join(dir, `${safeSlug}.toml`);
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- return true;
- }
- return false;
-}
-
-// ─── Internal ────────────────────────────────────────────────────
-
-function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] {
- if (!fs.existsSync(dir)) return [];
-
- const results: AgentDefinition[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".toml")) continue;
-
- const filePath = path.join(dir, entry.name);
- const slug = entry.name.slice(0, -5); // remove .toml
-
- try {
- const raw = fs.readFileSync(filePath, "utf-8");
- const parsed = parseTOML(raw);
-
- const models: AgentModelEntry[] = [];
- if (Array.isArray(parsed.models)) {
- for (const m of parsed.models) {
- if (m && typeof m === "object" && "key_id" in m && "model_id" in m) {
- const rawEffort = (m as Record<string, unknown>).effort;
- models.push({
- key_id: String((m as Record<string, unknown>).key_id),
- model_id: String((m as Record<string, unknown>).model_id),
- // Only carry `effort` when it's a recognised level; an
- // unset or invalid value falls back to the per-tab /
- // default effort at the call site.
- ...(isReasoningEffort(rawEffort) ? { effort: rawEffort } : {}),
- });
- }
- }
- }
-
- const skills: string[] = [];
- if (Array.isArray(parsed.skills)) {
- for (const s of parsed.skills) {
- if (typeof s === "string") skills.push(s);
- }
- }
-
- const tools: string[] = [];
- if (Array.isArray(parsed.tools)) {
- for (const t of parsed.tools) {
- if (typeof t === "string") tools.push(t);
- }
- }
-
- results.push({
- name: typeof parsed.name === "string" ? parsed.name : slug,
- description: typeof parsed.description === "string" ? parsed.description : "",
- skills,
- tools,
- models,
- scope,
- slug,
- ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}),
- ...(parsed.is_subagent === true ? { is_subagent: true } : {}),
- });
- } catch {
- // Skip unparseable files
- }
- }
-
- return results;
-}
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
deleted file mode 100644
index 4ca6fe1..0000000
--- a/packages/core/src/chunks/append.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-/**
- * Chunk-builder helper.
- *
- * `appendEventToChunks` is the single source of truth for how a stream of
- * `AgentEvent`s collapses into an ordered `Chunk[]` on a message. Both the
- * backend (agent + agent-manager) and the frontend store call this helper
- * so the wire format stays in lockstep across the boundary.
- *
- * Open/close rules — see notes/plan-chunk-refactor.md for the full table.
- *
- * | Chunk | Opens on | Coalesces |
- * |---------------|-----------------------------------------------------------------|------------------------------------------------------------|
- * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` |
- * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` |
- * | | OR after the last thinking chunk was sealed by `reasoning-end` | (only into the most recent UNSEALED thinking chunk) |
- * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`|
- * | `error` | every `error` event | NEVER (always single-event) |
- * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) |
- *
- * Side-effect events (no new chunk):
- * - `tool-result` → finds the call by `id` across all `tool-batch`
- * chunks (most-recent first) and updates its
- * `result` / `isError`.
- * - `shell-output` → appends to the most recent entry of the most
- * recent `tool-batch` chunk.
- * - `reasoning-end` → attaches `metadata` (the AI SDK v6
- * `providerMetadata` blob) to the most recent
- * UNSEALED `thinking` chunk. The metadata is also
- * the "sealed" marker — subsequent
- * `reasoning-delta`s will open a new chunk rather
- * than extending this one. Anthropic's signature
- * lives inside this blob; round-tripping it on the
- * next turn is mandatory for Anthropic to accept
- * the conversation. Orphan `reasoning-end` events
- * (no unsealed thinking chunk) are dropped.
- *
- * Ignored events:
- * - `status`, `turn-start`, `turn-sealed`, `done`, `usage`,
- * `task-list-update`, `tab-created`, `message-queued`, `message-consumed`,
- * `message-cancelled` — these are control / lifecycle events, not message
- * content.
- */
-
-import type {
- AgentEvent,
- ChatMessage,
- Chunk,
- MessageRole,
- SystemChunk,
- SystemChunkKind,
- ToolBatchChunk,
-} from "../types/index.js";
-
-/**
- * Mutates `chunks` in place based on `event`.
- *
- * Returns void; the array is the output channel.
- */
-export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
- switch (event.type) {
- case "text-delta": {
- // Open or extend the current text chunk.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "text") {
- last.text += event.delta;
- } else {
- chunks.push({ type: "text", text: event.delta });
- }
- return;
- }
-
- case "reasoning-delta": {
- // Open a new thinking chunk if the last chunk is not a thinking
- // chunk OR if it's already sealed by metadata. Anthropic emits
- // each thinking content block with its own metadata; a fresh
- // reasoning-delta after a sealed thinking chunk is the start of
- // a new block, not a continuation — extending the sealed chunk
- // would corrupt the metadata/text mapping.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "thinking" && last.metadata === undefined) {
- last.text += event.delta;
- } else {
- chunks.push({ type: "thinking", text: event.delta });
- }
- return;
- }
-
- case "reasoning-end": {
- // Attach `providerMetadata` to the most recent unsealed
- // thinking chunk. Anthropic's signature lives inside this
- // blob; without it on the next request, Anthropic rejects the
- // thinking block. The walk-back is a defensive backstop —
- // Anthropic's SSE delivers a content block's deltas strictly
- // in order and `appendEventToChunks` runs synchronously per
- // event, so the most recent thinking chunk is normally the
- // last chunk in the array.
- if (event.metadata === undefined) return;
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "thinking") continue;
- if (c.metadata !== undefined) {
- // Already sealed; the orphan metadata has no home.
- return;
- }
- c.metadata = event.metadata;
- return;
- }
- // No thinking chunk found at all — drop silently.
- return;
- }
-
- case "tool-call": {
- // Open or extend the current tool-batch chunk.
- const last = chunks[chunks.length - 1];
- const entry = {
- id: event.toolCall.id,
- name: event.toolCall.name,
- arguments: event.toolCall.arguments,
- };
- if (last && last.type === "tool-batch") {
- last.calls.push(entry);
- } else {
- chunks.push({ type: "tool-batch", calls: [entry] });
- }
- return;
- }
-
- case "tool-result": {
- // Find the matching call (by id) across all tool-batch chunks,
- // most-recent first. Tool results can arrive after subsequent
- // text-deltas, so we cannot rely on the *last* chunk being the
- // tool-batch — we have to search.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const call = c.calls.find((e) => e.id === event.toolResult.toolCallId);
- if (call) {
- call.result = event.toolResult.result;
- call.isError = event.toolResult.isError;
- return;
- }
- }
- // Orphan result with no matching call — drop silently.
- return;
- }
-
- case "shell-output": {
- // Append to the most recent entry of the most recent tool-batch.
- // Walk back through chunks to find the latest tool-batch; if there
- // are intervening text/thinking/etc chunks (which can happen if
- // the model streams text while a shell tool is still running),
- // we still want the most recent tool-batch.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const entry = c.calls[c.calls.length - 1];
- if (!entry) return;
- const prev = entry.shellOutput ?? { stdout: "", stderr: "" };
- entry.shellOutput = {
- stdout: prev.stdout + (event.stream === "stdout" ? event.data : ""),
- stderr: prev.stderr + (event.stream === "stderr" ? event.data : ""),
- };
- return;
- }
- // Orphan shell-output with no tool-batch in scope — drop silently.
- return;
- }
-
- case "error": {
- // Always a fresh single-event chunk — no coalescing.
- chunks.push({
- type: "error",
- message: event.error,
- ...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}),
- });
- return;
- }
-
- case "notice": {
- chunks.push({ type: "system", kind: "notice", text: event.message });
- return;
- }
-
- case "model-changed": {
- chunks.push({
- type: "system",
- kind: "model-changed",
- text: `Switched to ${event.modelId} (${event.keyId})`,
- });
- return;
- }
-
- case "config-reload": {
- chunks.push({
- type: "system",
- kind: "config-reload",
- text: "Configuration reloaded",
- });
- return;
- }
-
- // Lifecycle / control events — no chunk emitted.
- case "status":
- case "turn-start":
- case "turn-sealed":
- case "done":
- case "usage":
- case "task-list-update":
- case "tab-created":
- case "message-queued":
- case "message-consumed":
- case "message-cancelled":
- case "compaction-started":
- case "compaction-complete":
- case "compaction-error":
- return;
-
- default: {
- // Exhaustiveness check — if a new event variant is added to
- // AgentEvent, TypeScript will complain here.
- const _exhaustive: never = event;
- void _exhaustive;
- return;
- }
- }
-}
-
-// ─── System event routing across messages ────────────────────────
-
-/**
- * Minimal shape needed by `applySystemEvent`.
- *
- * The caller (agent-manager / persistence layer) typically tracks message
- * id alongside the wire-format `ChatMessage`. This generic constraint
- * lets us keep core `ChatMessage` clean while still letting downstream
- * pass anything with an `id`.
- */
-export interface IdentifiedMessage {
- id: string;
- role: MessageRole;
- chunks: Chunk[];
-}
-
-/**
- * Describes the system event in caller-controlled terms. We let the caller
- * decide both the `kind` (so the same helper can record cancellations,
- * notices, model swaps, etc.) and the `text` (so the caller controls
- * formatting / localization).
- */
-export interface SystemEventLike {
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * Routes a system event to the right message when *no assistant turn is
- * in flight*. (When a turn IS in flight, the caller should instead use
- * `appendEventToChunks` against the in-flight message's chunks directly.)
- *
- * Routing rules (from notes/plan-chunk-refactor.md):
- *
- * 1. Most recent message is `role: "system"` → append a `system` chunk
- * to it. (Note: a second consecutive system event creates a second
- * system chunk inside the same system message — chunks themselves
- * never coalesce.)
- * 2. Otherwise → create a fresh `role: "system"` message containing one
- * `system` chunk and push it.
- *
- * Returns the `messageId` that was used (either the existing system
- * message's id or the newly-created one) so the caller can persist /
- * emit a diff to subscribers.
- *
- * `idFactory` defaults to `crypto.randomUUID()`; tests inject a
- * deterministic factory.
- */
-export function applySystemEvent<M extends IdentifiedMessage>(
- messages: M[],
- event: SystemEventLike,
- idFactory: () => string = defaultIdFactory,
-): { messageId: string } {
- const chunk: SystemChunk = { type: "system", kind: event.kind, text: event.text };
-
- const last = messages[messages.length - 1];
- if (last && last.role === "system") {
- last.chunks.push(chunk);
- return { messageId: last.id };
- }
-
- const id = idFactory();
- // We can't fabricate the full `M` shape without knowing its extra
- // fields, but `IdentifiedMessage` is the minimum we need to push.
- // Callers that extend the shape with extra fields are responsible for
- // initializing them via post-hoc patching, or by passing in their own
- // message-creation logic. In practice callers either:
- // (a) use `ChatMessage` itself (no extra fields beyond IdentifiedMessage), or
- // (b) construct messages and look them up by id after this call returns.
- const newMessage = { id, role: "system" as const, chunks: [chunk] } as unknown as M;
- messages.push(newMessage);
- return { messageId: id };
-}
-
-function defaultIdFactory(): string {
- // In Node 19+ / modern browsers, `crypto.randomUUID` is available globally.
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- // Fallback: pseudo-random; not cryptographically secure, but adequate for
- // in-memory message identifiers when randomUUID is unavailable.
- return `sysmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-}
-
-// ─── Re-exports for convenience ──────────────────────────────────
-
-export type { ChatMessage, Chunk, SystemChunk, SystemChunkKind, ToolBatchChunk };
diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts
deleted file mode 100644
index e8f4a18..0000000
--- a/packages/core/src/chunks/transform.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-// Pure, dependency-free transforms between the render-shaped `Chunk[]` /
-// `ChatMessage` model and the flat append-only `ChunkRow` log. Kept free of any
-// DB (`bun:sqlite`) import so BOTH the backend persistence layer
-// (`db/chunks.ts`) and the browser frontend store can share the exact same
-// explode/group logic.
-
-import type {
- Chunk,
- ChunkRow,
- ChunkRowDraft,
- ErrorData,
- MessageRole,
- SystemData,
- TextData,
- ThinkingData,
- ToolBatchChunk,
- ToolCallData,
- ToolResultData,
-} from "../types/index.js";
-
-/**
- * A DERIVED message — a grouping of contiguous chunk rows reconstructed for the
- * agent's in-memory history and for the frontend's render bubbles. NOT a stored
- * shape: the source of truth is the flat chunk log.
- */
-export interface MessageRow {
- id: string;
- tabId: string;
- seq: number;
- /** turn_id of the chunk rows this message was grouped from. */
- turnId: string;
- role: MessageRole;
- chunks: Chunk[];
- createdAt: number;
-}
-
-// ─── Explode: in-memory turn → flat chunk-row drafts ─────────────
-//
-// A turn's render-shaped `Chunk[]` is flattened into append-only rows.
-// `tool-batch` chunks are split into SEPARATE `tool_call` (role=assistant) and
-// `tool_result` (role=tool) rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format. `step` increments after each tool-batch: every LLM
-// round-trip emits text/thinking then (optionally) one tool-batch, so a
-// tool-batch boundary is exactly a step boundary.
-
-/** Explode a single user message's text into one row draft. */
-export function explodeUserText(turnId: string, text: string): ChunkRowDraft[] {
- return [{ turnId, step: 0, role: "user", type: "text", data: { text } }];
-}
-
-/** Explode an assistant turn's accumulated chunks into ordered row drafts. */
-export function explodeTurn(turnId: string, chunks: Chunk[]): ChunkRowDraft[] {
- const drafts: ChunkRowDraft[] = [];
- let step = 0;
- for (const chunk of chunks) {
- switch (chunk.type) {
- case "text":
- drafts.push({ turnId, step, role: "assistant", type: "text", data: { text: chunk.text } });
- break;
- case "thinking":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "thinking",
- data: {
- text: chunk.text,
- ...(chunk.metadata !== undefined ? { metadata: chunk.metadata } : {}),
- },
- });
- break;
- case "tool-batch": {
- for (const call of chunk.calls) {
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "tool_call",
- data: { callId: call.id, name: call.name, arguments: call.arguments },
- });
- }
- for (const call of chunk.calls) {
- if (call.result === undefined) continue;
- drafts.push({
- turnId,
- step,
- role: "tool",
- type: "tool_result",
- data: {
- callId: call.id,
- name: call.name,
- result: call.result,
- isError: call.isError ?? false,
- ...(call.shellOutput !== undefined ? { shellOutput: call.shellOutput } : {}),
- },
- });
- }
- // A tool-batch ends the current LLM step; subsequent chunks belong
- // to the next round-trip.
- step++;
- break;
- }
- case "error":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "error",
- data: {
- message: chunk.message,
- ...(chunk.statusCode !== undefined ? { statusCode: chunk.statusCode } : {}),
- },
- });
- break;
- case "system":
- drafts.push({
- turnId,
- step,
- role: "system",
- type: "system",
- data: { kind: chunk.kind, text: chunk.text },
- });
- break;
- }
- }
- return drafts;
-}
-
-// ─── Group: flat chunk rows → derived render messages ────────────
-//
-// The inverse of explode (best-effort over an arbitrary window, so it tolerates
-// orphan tool-results whose tool-call was paged out). `tool_result` rows
-// (role=tool) merge back into the preceding assistant message's per-step
-// `tool-batch` chunk by `callId` rather than forming their own message.
-
-export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] {
- const messages: MessageRow[] = [];
-
- let current: { msg: MessageRow; batches: Map<number, ToolBatchChunk> } | null = null;
- const flush = () => {
- if (current) {
- messages.push(current.msg);
- current = null;
- }
- };
- const ensureAssistant = (row: ChunkRow) => {
- if (!current) {
- current = {
- msg: {
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "assistant",
- chunks: [],
- createdAt: row.createdAt,
- },
- batches: new Map(),
- };
- }
- return current;
- };
- const ensureBatch = (step: number): ToolBatchChunk => {
- const c = current;
- if (!c) throw new Error("ensureBatch called without an assistant message");
- let batch = c.batches.get(step);
- if (!batch) {
- batch = { type: "tool-batch", calls: [] };
- c.batches.set(step, batch);
- c.msg.chunks.push(batch);
- }
- return batch;
- };
-
- for (const row of rows) {
- if (row.role === "user") {
- flush();
- const d = row.data as TextData;
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "user",
- chunks: [{ type: "text", text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
- if (row.role === "system") {
- // Coalesce consecutive system rows into one system message (multiple
- // system chunks), matching the old applySystemEvent behaviour.
- const prev = messages[messages.length - 1];
- const d = row.data as SystemData;
- if (current === null && prev && prev.role === "system") {
- prev.chunks.push({ type: "system", kind: d.kind, text: d.text });
- continue;
- }
- flush();
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "system",
- chunks: [{ type: "system", kind: d.kind, text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
-
- // Usage rows are an invisible side channel (persisted for the backend
- // aggregate only). They're already query-excluded from getChunksForTab,
- // so this is defensive insurance: never let one leak into render grouping.
- if (row.type === "usage") continue;
-
- // assistant / tool rows → part of the current assistant message
- const c = ensureAssistant(row);
- switch (row.type) {
- case "text":
- c.msg.chunks.push({ type: "text", text: (row.data as TextData).text });
- break;
- case "thinking": {
- const d = row.data as ThinkingData;
- c.msg.chunks.push({
- type: "thinking",
- text: d.text,
- ...(d.metadata !== undefined ? { metadata: d.metadata } : {}),
- });
- break;
- }
- case "error": {
- const d = row.data as ErrorData;
- c.msg.chunks.push({
- type: "error",
- message: d.message,
- ...(d.statusCode !== undefined ? { statusCode: d.statusCode } : {}),
- });
- break;
- }
- case "tool_call": {
- const d = row.data as ToolCallData;
- ensureBatch(row.step).calls.push({ id: d.callId, name: d.name, arguments: d.arguments });
- break;
- }
- case "tool_result": {
- const d = row.data as ToolResultData;
- const batch = ensureBatch(row.step);
- const entry = batch.calls.find((e) => e.id === d.callId);
- if (entry) {
- entry.result = d.result;
- entry.isError = d.isError;
- if (d.shellOutput !== undefined) entry.shellOutput = d.shellOutput;
- } else {
- // Orphan result (its tool_call was paged out of this window).
- batch.calls.push({
- id: d.callId,
- name: d.name,
- arguments: {},
- result: d.result,
- isError: d.isError,
- ...(d.shellOutput !== undefined ? { shellOutput: d.shellOutput } : {}),
- });
- }
- break;
- }
- }
- }
- flush();
- return messages;
-}
diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts
deleted file mode 100644
index 663ccb2..0000000
--- a/packages/core/src/compaction/index.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-// Conversation compaction — summarize the older "head" of a conversation into
-// a structured anchor while preserving the most recent turns verbatim.
-//
-// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the
-// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The
-// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed
-// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns
-// verbatim rather than computing a token budget.
-//
-// This module is pure and DB-free so it can be unit-tested in isolation and
-// shared by the API orchestrator.
-
-import type { ChatMessage } from "../types/index.js";
-
-/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */
-export const DEFAULT_TAIL_TURNS = 2;
-
-/** Max characters of a single tool result fed into the summary request. */
-export const TOOL_OUTPUT_MAX_CHARS = 2_000;
-
-/**
- * Marker prefixing the seeded summary turn in a compacted conversation. Lets a
- * subsequent compaction detect a prior summary and re-summarize (anchor) it
- * instead of treating it as ordinary conversation.
- */
-export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]";
-
-/**
- * Structured Markdown template the summary must follow. Ported verbatim from
- * opencode's `SUMMARY_TEMPLATE`.
- */
-export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
-<template>
-## Goal
-- [single-sentence task summary]
-
-## Constraints & Preferences
-- [user constraints, preferences, specs, or "(none)"]
-
-## Progress
-### Done
-- [completed work or "(none)"]
-
-### In Progress
-- [current work or "(none)"]
-
-### Blocked
-- [blockers or "(none)"]
-
-## Key Decisions
-- [decision and why, or "(none)"]
-
-## Next Steps
-- [ordered next actions or "(none)"]
-
-## Critical Context
-- [important technical facts, errors, open questions, or "(none)"]
-
-## Relevant Files
-- [file or directory path: why it matters, or "(none)"]
-</template>
-
-Rules:
-- Keep every section, even when empty.
-- Use terse bullets, not prose paragraphs.
-- Preserve exact file paths, commands, error strings, and identifiers when known.
-- Do not mention the summary process or that context was compacted.`;
-
-/**
- * Build the compaction instruction. When `previousSummary` is provided, the
- * model is asked to UPDATE the anchored summary rather than create a fresh one
- * (opencode's `buildPrompt` anchor behaviour).
- */
-export function buildCompactionPrompt(input: { previousSummary?: string }): string {
- const anchor = input.previousSummary
- ? [
- "Update the anchored summary below using the conversation history above.",
- "Preserve still-true details, remove stale details, and merge in the new facts.",
- "<previous-summary>",
- input.previousSummary,
- "</previous-summary>",
- ].join("\n")
- : "Create a new anchored summary from the conversation history above.";
- return `${anchor}\n\n${SUMMARY_TEMPLATE}`;
-}
-
-/**
- * The first text chunk of a message, trimmed (empty → undefined). Used to read
- * the seeded summary out of a compacted conversation's first user turn.
- */
-function firstText(message: ChatMessage): string | undefined {
- for (const chunk of message.chunks) {
- if (chunk.type === "text") {
- const t = chunk.text.trim();
- if (t) return t;
- }
- }
- return undefined;
-}
-
-/**
- * Extract a prior summary from the conversation head. If the first user message
- * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body
- * (marker stripped) so the next compaction can anchor on it.
- */
-export function extractPreviousSummary(messages: ChatMessage[]): string | undefined {
- const first = messages.find((m) => m.role === "user");
- if (!first) return undefined;
- const text = firstText(first);
- if (!text?.startsWith(SUMMARY_MARKER)) return undefined;
- const body = text.slice(SUMMARY_MARKER.length).trim();
- return body || undefined;
-}
-
-export interface HeadTailSelection<T extends ChatMessage = ChatMessage> {
- /** Older messages to be summarized away. */
- head: T[];
- /** Recent messages preserved verbatim in the continuation. */
- tail: T[];
-}
-
-/**
- * Split a conversation into a summarizable `head` and a preserved `tail` of the
- * last `tailTurns` turns. A "turn" begins at a user message and runs until the
- * next user message.
- *
- * When the conversation has `tailTurns` or fewer turns, `head` is empty: there
- * is nothing to compact (the caller should refuse).
- */
-export function selectHeadTail<T extends ChatMessage>(
- messages: T[],
- tailTurns: number = DEFAULT_TAIL_TURNS,
-): HeadTailSelection<T> {
- if (tailTurns <= 0) return { head: messages, tail: [] };
- const userIndices: number[] = [];
- for (let i = 0; i < messages.length; i++) {
- if (messages[i]?.role === "user") userIndices.push(i);
- }
- if (userIndices.length <= tailTurns) return { head: [], tail: messages };
- const tailStart = userIndices[userIndices.length - tailTurns];
- if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages };
- return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) };
-}
-
-/** Cap a tool result to `max` chars with a truncation marker. */
-function capToolOutput(result: string, max: number): string {
- if (result.length <= max) return result;
- const omitted = result.length - max;
- return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`;
-}
-
-/**
- * Render conversation messages into a compact, provider-agnostic plain-text
- * transcript suitable as summary-request context. Tool results are capped at
- * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior
- * summary message is skipped (its content is carried by the prompt anchor).
- * Thinking/error/system chunks are omitted as summary noise.
- */
-export function renderTranscript(
- messages: ChatMessage[],
- toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS,
-): string {
- const blocks: string[] = [];
- for (const message of messages) {
- // Skip a seeded prior-summary user turn — it's represented via the anchor.
- if (message.role === "user") {
- const t = firstText(message);
- if (t?.startsWith(SUMMARY_MARKER)) continue;
- }
-
- const lines: string[] = [];
- for (const chunk of message.chunks) {
- if (chunk.type === "text") {
- const t = chunk.text.trim();
- if (t) lines.push(t);
- } else if (chunk.type === "tool-batch") {
- for (const call of chunk.calls) {
- let args = "";
- try {
- args = JSON.stringify(call.arguments ?? {});
- } catch {
- args = "{}";
- }
- lines.push(`[tool ${call.name} ${args}]`);
- if (call.result !== undefined) {
- const tag = call.isError ? "tool-error" : "tool-result";
- lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`);
- }
- }
- }
- }
- if (lines.length === 0) continue;
- const role =
- message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System";
- blocks.push(`## ${role}\n${lines.join("\n")}`);
- }
- return blocks.join("\n\n");
-}
-
-export interface CompactionRequest<T extends ChatMessage = ChatMessage> {
- /** Messages selected for summarization (older head). */
- head: T[];
- /** Recent messages preserved verbatim (last N turns). */
- tail: T[];
- /** Prior summary anchored on, if the conversation was compacted before. */
- previousSummary?: string;
- /**
- * The full user-message content for the summary request: rendered head
- * transcript followed by the compaction prompt/template. `undefined` when
- * there is nothing to compact (`head` empty).
- */
- prompt?: string;
-}
-
-/**
- * Assemble everything needed to run a compaction: head/tail split, prior-summary
- * extraction, and the combined summary-request prompt. Returns `prompt:
- * undefined` when the conversation is too short to compact.
- */
-export function buildCompactionRequest<T extends ChatMessage>(input: {
- messages: T[];
- tailTurns?: number;
- toolOutputMaxChars?: number;
-}): CompactionRequest<T> {
- const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS;
- const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS;
- const { head, tail } = selectHeadTail(input.messages, tailTurns);
- const previousSummary = extractPreviousSummary(input.messages);
- if (head.length === 0) {
- return { head, tail, previousSummary };
- }
- const transcript = renderTranscript(head, toolMax);
- const instruction = buildCompactionPrompt({ previousSummary });
- const prompt = `${transcript}\n\n${instruction}`;
- return { head, tail, previousSummary, prompt };
-}
-
-/**
- * Wrap a generated summary as the seeded user-turn text for the continuation
- * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can
- * anchor on it.
- */
-export function buildSummaryTurnText(summary: string): string {
- return `${SUMMARY_MARKER}\n\n${summary.trim()}`;
-}
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
deleted file mode 100644
index 7f76dd7..0000000
--- a/packages/core/src/config/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export {
- configToRuleset,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
-} from "./loader.js";
-export { validateConfig } from "./schema.js";
-export { createConfigWatcher, watchDirConfig } from "./watcher.js";
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
deleted file mode 100644
index 66f798b..0000000
--- a/packages/core/src/config/loader.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { readFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import { parse } from "smol-toml";
-import type { PermissionRule, Ruleset } from "../permission/index.js";
-import type { DispatchConfig, KeyDefinition, LspServerConfig } from "../types/index.js";
-import { validateConfig } from "./schema.js";
-
-const DEFAULT_CONFIG: DispatchConfig = { permissions: {} };
-
-const VALID_ACTIONS = new Set(["allow", "deny", "ask"]);
-
-function validateAction(raw: string): "allow" | "deny" | "ask" {
- if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask";
- console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`);
- return "ask";
-}
-
-/**
- * Absolute path to the HOME-directory (global) `dispatch.toml`.
- *
- * Follows the same `~/.config/dispatch/` convention as global agents
- * (`~/.config/dispatch/agents`). This file is OPTIONAL; when present its
- * contents are merged underneath every project/working-directory config so
- * machine-wide settings (e.g. globally available LSP servers) work in any
- * repository without per-repo configuration.
- *
- * The path can be overridden with the `DISPATCH_GLOBAL_CONFIG` environment
- * variable, which is primarily useful for tests (point it at a temp file) but
- * also lets a user relocate the global config.
- */
-export function getGlobalConfigPath(): string {
- return (
- process.env.DISPATCH_GLOBAL_CONFIG ?? join(homedir(), ".config", "dispatch", "dispatch.toml")
- );
-}
-
-// Parse + validate a single dispatch.toml. Returns null when the file does not
-// exist. Re-throws TOML parse errors so a corrupt LOCAL config surfaces loudly
-// (callers that must stay resilient, e.g. the global loader, catch it).
-function readConfigFile(tomlPath: string): DispatchConfig | null {
- let raw: unknown;
- try {
- const content = readFileSync(tomlPath, "utf-8");
- raw = parse(content);
- } catch (err: unknown) {
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
- // File doesn't exist — signal "no config here".
- return null;
- }
- console.warn(
- `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`,
- );
- throw err;
- }
-
- const { config, errors } = validateConfig(raw);
- for (const e of errors) {
- console.warn(`dispatch: config warning at ${e.path}: ${e.message}`);
- }
- return config;
-}
-
-/**
- * Load the HOME-directory global `dispatch.toml` (see {@link getGlobalConfigPath}).
- *
- * Always resilient: a missing file yields the empty default and a malformed
- * file is logged but downgraded to the empty default rather than thrown. A
- * broken global config must never break config loading for every repository on
- * the machine.
- */
-export function loadGlobalConfig(): DispatchConfig {
- try {
- return readConfigFile(getGlobalConfigPath()) ?? DEFAULT_CONFIG;
- } catch (err) {
- console.warn(
- `dispatch: ignoring global config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
- );
- return DEFAULT_CONFIG;
- }
-}
-
-/**
- * Load the effective config for `dir`: the global config MERGED with the
- * project/working-directory `dispatch.toml`, where the LOCAL config takes
- * precedence on conflicts (see {@link mergeConfigs}). A missing local file
- * yields the global config as-is; a missing global file yields the local
- * config as-is.
- *
- * Note: a malformed LOCAL config still throws (callers may surface it), while a
- * malformed GLOBAL config is downgraded to empty by {@link loadGlobalConfig}.
- */
-export function loadConfig(dir: string): DispatchConfig {
- const global = loadGlobalConfig();
- const local = readConfigFile(join(dir, "dispatch.toml"));
- if (local === null) return global;
- return mergeConfigs(global, local);
-}
-
-// ─── Merge ───────────────────────────────────────────────────────
-
-/**
- * Merge two permission blocks. Local takes precedence on conflicts.
- *
- * - A key present only in one side is carried over verbatim.
- * - A key whose value is a string on either side: local replaces global.
- * - A key that is a nested `{ pattern -> action }` object on BOTH sides is
- * merged pattern-by-pattern: global patterns the local block does NOT also
- * define come first (original order), then EVERY local pattern is appended
- * last (overriding any same-named global pattern).
- *
- * Emitting all local patterns after the global ones is essential, not
- * cosmetic: `configToRuleset` flattens patterns in iteration order and
- * `evaluate` uses `findLast` (last match wins). If an overridden pattern were
- * updated in place, a more-general global pattern (e.g. "*") could remain AFTER
- * it and silently shadow the local override. Appending local patterns last
- * reproduces a clean "global rules then local rules" concatenation so local
- * always wins.
- */
-function mergePermissions(
- global: DispatchConfig["permissions"],
- local: DispatchConfig["permissions"],
-): DispatchConfig["permissions"] {
- const result: DispatchConfig["permissions"] = {};
- for (const [key, value] of Object.entries(global)) {
- result[key] = value;
- }
- for (const [key, value] of Object.entries(local)) {
- const existing = result[key];
- if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") {
- // Both nested objects — merge patterns so that ALL local patterns
- // are emitted AFTER the global ones. This matters because
- // `configToRuleset` flattens patterns in insertion order and
- // `evaluate` uses `findLast` (last match wins): a naive
- // `{ ...existing, ...value }` would update an overridden pattern
- // IN PLACE, leaving a more-general global pattern (e.g. "*") sitting
- // AFTER it and silently shadowing the local override. We therefore
- // drop any global pattern that the local block also defines, keep the
- // remaining global patterns in their original order, then append every
- // local pattern last — reproducing a clean "global rules then local
- // rules" concatenation where local always wins.
- const merged: Record<string, string> = {};
- for (const [pattern, action] of Object.entries(existing)) {
- if (!(pattern in value)) merged[pattern] = action;
- }
- for (const [pattern, action] of Object.entries(value)) {
- merged[pattern] = action;
- }
- result[key] = merged;
- } else {
- // Local string, brand-new key, or a string/object type mismatch:
- // local replaces global wholesale.
- result[key] = value;
- }
- }
- return result;
-}
-
-/**
- * Merge two key lists by `id`. Local keys override global keys sharing the same
- * id; non-conflicting ids from both lists survive. Global keys keep their
- * relative order (overridden in place) followed by local-only keys.
- */
-function mergeKeys(global: KeyDefinition[], local: KeyDefinition[]): KeyDefinition[] {
- const byId = new Map<string, KeyDefinition>();
- for (const key of global) byId.set(key.id, key);
- for (const key of local) byId.set(key.id, key);
- return Array.from(byId.values());
-}
-
-/**
- * Merge two `[lsp]` blocks by server id. Local servers override global servers
- * sharing the same id; non-conflicting ids from both sides remain active. This
- * is what lets a global config provide LSP servers to every repository while a
- * project can still override or add its own.
- */
-function mergeLsp(
- global: Record<string, LspServerConfig>,
- local: Record<string, LspServerConfig>,
-): Record<string, LspServerConfig> {
- return { ...global, ...local };
-}
-
-/**
- * Deep-merge a `global` config with a `local` (project/working-directory)
- * config, with LOCAL taking precedence on every conflict. Pure function — does
- * not touch the filesystem and never mutates its inputs.
- */
-export function mergeConfigs(global: DispatchConfig, local: DispatchConfig): DispatchConfig {
- const merged: DispatchConfig = {
- permissions: mergePermissions(global.permissions, local.permissions),
- };
-
- if (global.keys !== undefined || local.keys !== undefined) {
- merged.keys = mergeKeys(global.keys ?? [], local.keys ?? []);
- }
-
- if (global.lsp !== undefined || local.lsp !== undefined) {
- merged.lsp = mergeLsp(global.lsp ?? {}, local.lsp ?? {});
- }
-
- return merged;
-}
-
-// Convert the config's permission block to a Ruleset
-export function configToRuleset(config: DispatchConfig): Ruleset {
- const home = homedir();
- const rules: PermissionRule[] = [];
-
- for (const [permission, value] of Object.entries(config.permissions)) {
- if (typeof value === "string") {
- const action = validateAction(value);
- rules.push({ permission, pattern: "*", action });
- } else {
- for (const [rawPattern, rawAction] of Object.entries(value)) {
- const pattern = rawPattern
- .replace(/^\$HOME(?=[/\\]|$)/, home)
- .replace(/^~(?=[/\\]|$)/, home);
- const action = validateAction(rawAction);
- rules.push({ permission, pattern, action });
- }
- }
- }
-
- return rules;
-}
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
deleted file mode 100644
index 304ee10..0000000
--- a/packages/core/src/config/schema.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import type {
- ConfigError,
- DispatchConfig,
- KeyDefinition,
- LspServerConfig,
-} from "../types/index.js";
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function isStringRecord(value: unknown): value is Record<string, string> {
- if (!isRecord(value)) return false;
- return Object.values(value).every((v) => typeof v === "string");
-}
-
-function isValidAction(value: string): boolean {
- return value === "allow" || value === "deny" || value === "ask";
-}
-
-function isPermissionsValue(value: unknown): value is string | Record<string, string> {
- return typeof value === "string" || isStringRecord(value);
-}
-
-function validatePermissions(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): Record<string, string | Record<string, string>> {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return {};
- }
- const result: Record<string, string | Record<string, string>> = {};
- for (const [key, value] of Object.entries(raw)) {
- if (!isPermissionsValue(value)) {
- errors.push({
- path: `${path}.${key}`,
- message: "must be a string or a flat string-keyed object",
- });
- continue;
- }
- if (typeof value === "string") {
- if (!isValidAction(value)) {
- errors.push({
- path: `${path}.${key}`,
- message: `invalid action "${value}"; must be "allow", "deny", or "ask"`,
- });
- continue;
- }
- } else {
- let hasError = false;
- for (const [pattern, action] of Object.entries(value)) {
- if (!isValidAction(action)) {
- errors.push({
- path: `${path}.${key}.${pattern}`,
- message: `invalid action "${action}"; must be "allow", "deny", or "ask"`,
- });
- hasError = true;
- }
- }
- if (hasError) continue;
- }
- result[key] = value;
- }
- return result;
-}
-
-function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return null;
- }
- if (typeof raw.id !== "string") {
- errors.push({ path: `${path}.id`, message: "must be a string" });
- return null;
- }
- if (typeof raw.provider !== "string") {
- errors.push({ path: `${path}.provider`, message: "must be a string" });
- return null;
- }
- if (typeof raw.base_url !== "string") {
- errors.push({ path: `${path}.base_url`, message: "must be a string" });
- return null;
- }
-
- // "anthropic" provider uses credentials_file instead of env
- if (raw.provider === "anthropic") {
- return {
- id: raw.id as string,
- provider: raw.provider as string,
- base_url: raw.base_url as string,
- ...(typeof raw.credentials_file === "string"
- ? ({ credentials_file: raw.credentials_file } as Pick<KeyDefinition, "credentials_file">)
- : {}),
- };
- }
-
- // Other providers: env is optional (keys can be stored in DB)
- return {
- id: raw.id as string,
- provider: raw.provider as string,
- base_url: raw.base_url as string,
- ...(typeof raw.env === "string" ? { env: raw.env } : {}),
- };
-}
-
-function isStringArray(value: unknown): value is string[] {
- return Array.isArray(value) && value.every((v) => typeof v === "string");
-}
-
-function validateLspServer(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): LspServerConfig | null {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return null;
- }
-
- const disabled = raw.disabled === true;
-
- // `command` is required and must be a non-empty string array unless the
- // entry is explicitly disabled (a disabled entry is skipped wholesale).
- if (!disabled) {
- if (!isStringArray(raw.command) || raw.command.length === 0) {
- errors.push({
- path: `${path}.command`,
- message: "must be a non-empty array of strings",
- });
- return null;
- }
- // `extensions` is required for custom servers — without it the client
- // cannot know which files should activate the server.
- if (!isStringArray(raw.extensions) || raw.extensions.length === 0) {
- errors.push({
- path: `${path}.extensions`,
- message: 'must be a non-empty array of strings (e.g. [".luau"])',
- });
- return null;
- }
- } else {
- // Disabled entries still must not carry a malformed command/extensions
- // if present, but we do not require them.
- if (raw.command !== undefined && !isStringArray(raw.command)) {
- errors.push({ path: `${path}.command`, message: "must be an array of strings" });
- return null;
- }
- if (raw.extensions !== undefined && !isStringArray(raw.extensions)) {
- errors.push({ path: `${path}.extensions`, message: "must be an array of strings" });
- return null;
- }
- }
-
- if (raw.env !== undefined && !isStringRecord(raw.env)) {
- errors.push({
- path: `${path}.env`,
- message: "must be a flat string-keyed object",
- });
- return null;
- }
-
- if (raw.initialization !== undefined && !isRecord(raw.initialization)) {
- errors.push({
- path: `${path}.initialization`,
- message: "must be an object",
- });
- return null;
- }
-
- const server: LspServerConfig = {
- command: (raw.command as string[] | undefined) ?? [],
- extensions: (raw.extensions as string[] | undefined) ?? [],
- ...(isStringRecord(raw.env) ? { env: raw.env } : {}),
- ...(isRecord(raw.initialization)
- ? { initialization: raw.initialization as Record<string, unknown> }
- : {}),
- ...(disabled ? { disabled: true } : {}),
- };
- return server;
-}
-
-function validateLsp(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): Record<string, LspServerConfig> | undefined {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return undefined;
- }
- const result: Record<string, LspServerConfig> = {};
- for (const [id, value] of Object.entries(raw)) {
- const server = validateLspServer(value, `${path}.${id}`, errors);
- if (server) result[id] = server;
- }
- return Object.keys(result).length > 0 ? result : undefined;
-}
-
-export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } {
- const errors: ConfigError[] = [];
-
- if (!isRecord(raw)) {
- errors.push({ path: "", message: "config must be an object" });
- return { config: { permissions: {} }, errors };
- }
-
- // permissions (required, but can be empty)
- const permissions = validatePermissions(raw.permissions ?? {}, "permissions", errors);
-
- // keys (optional)
- let keys: KeyDefinition[] | undefined;
- if (raw.keys !== undefined) {
- if (!Array.isArray(raw.keys)) {
- errors.push({ path: "keys", message: "must be an array" });
- } else {
- keys = [];
- for (let i = 0; i < raw.keys.length; i++) {
- const key = validateKey(raw.keys[i], `keys[${i}]`, errors);
- if (key) keys.push(key);
- }
- }
- }
-
- // lsp (optional)
- let lsp: Record<string, LspServerConfig> | undefined;
- if (raw.lsp !== undefined) {
- lsp = validateLsp(raw.lsp, "lsp", errors);
- }
-
- const config: DispatchConfig = {
- permissions,
- ...(keys !== undefined && { keys }),
- ...(lsp !== undefined && { lsp }),
- };
-
- return { config, errors };
-}
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
deleted file mode 100644
index ad55804..0000000
--- a/packages/core/src/config/watcher.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { join } from "node:path";
-import { watch } from "chokidar";
-import type { DispatchConfig } from "../types/index.js";
-import { getGlobalConfigPath, loadConfig } from "./loader.js";
-
-/**
- * Watch BOTH the HOME-directory global `dispatch.toml` and the project/working-
- * directory `dispatch.toml`. Either file changing triggers a reload that
- * re-merges global + local (via {@link loadConfig}), so hot-reload works for
- * global defaults and per-project overrides alike.
- *
- * When the global and local paths coincide (e.g. the working directory IS
- * `~/.config/dispatch`, or `DISPATCH_GLOBAL_CONFIG` points at the local file)
- * the duplicate is collapsed so chokidar only watches it once.
- */
-export function createConfigWatcher(
- dir: string,
- onChange: (config: DispatchConfig) => void,
-): { close(): void } {
- const localPath = join(dir, "dispatch.toml");
- const globalPath = getGlobalConfigPath();
- const paths = globalPath === localPath ? [localPath] : [globalPath, localPath];
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const watcher = watch(paths, {
- ignoreInitial: true,
- persistent: false,
- });
-
- const handleChange = () => {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- console.log(`dispatch: reloading config (global + ${localPath})`);
- try {
- const config = loadConfig(dir);
- onChange(config);
- } catch (err) {
- console.warn(
- `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }, 300);
- };
-
- watcher.on("change", handleChange);
- watcher.on("add", handleChange);
- watcher.on("unlink", handleChange);
-
- watcher.on("error", (err) => {
- console.warn(
- `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close().catch((err) => {
- console.warn(
- `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- },
- };
-}
-
-/**
- * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just
- * a debounced change signal). Used by the agent manager to invalidate its
- * per-directory LSP cache when a tab's effective working directory is a
- * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher`
- * only watches the root + global configs, so without this a nested config edit
- * would never clear `lspServersByDir[subdir]` and agents there would keep using
- * stale LSP servers until a root-config change or restart.
- *
- * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`.
- */
-export function watchDirConfig(dir: string, onChange: () => void): { close(): void } {
- const tomlPath = join(dir, "dispatch.toml");
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const watcher = watch(tomlPath, {
- ignoreInitial: true,
- persistent: false,
- });
-
- const handleChange = () => {
- if (debounceTimer !== null) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- try {
- onChange();
- } catch (err) {
- console.warn(
- `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }, 300);
- };
-
- watcher.on("change", handleChange);
- watcher.on("add", handleChange);
- watcher.on("unlink", handleChange);
- watcher.on("error", (err) => {
- console.warn(
- `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close().catch((err) => {
- console.warn(
- `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- },
- };
-}
diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts
deleted file mode 100644
index 802ebbd..0000000
--- a/packages/core/src/credentials/anthropic-betas.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// ─── Anthropic Beta Headers ───────────────────────────────────
-//
-// The set of `anthropic-beta` features the official Claude Code CLI sends on
-// every request. Kept in a dependency-free module (no DB / `bun:sqlite`
-// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in
-// without dragging the whole credentials/DB stack into its import graph.
-//
-// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the
-// Anthropic API silently ignores every `cache_control` breakpoint we place,
-// producing a 0% cache hit rate (see notes/claude-report.md). `oauth-2025-04-20`
-// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions.
-
-const BASE_BETAS = [
- "claude-code-20250219",
- "oauth-2025-04-20",
- "interleaved-thinking-2025-05-14",
- "prompt-caching-scope-2026-01-05",
- "context-management-2025-06-27",
- "advisor-tool-2026-03-01",
-];
-
-export function getAnthropicBetas(): string[] {
- return [...BASE_BETAS];
-}
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
deleted file mode 100644
index ef30400..0000000
--- a/packages/core/src/credentials/api-keys.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { getDatabase } from "../db/index.js";
-
-export interface StoredApiKey {
- keyId: string;
- provider: string;
- apiKey: string;
- importedAt: number;
- updatedAt: number;
-}
-
-/**
- * Store or update an API key in the database.
- */
-export function setApiKey(keyId: string, provider: string, apiKey: string): void {
- const db = getDatabase();
- const now = Date.now();
- db.query(
- `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at)
- VALUES ($keyId, $provider, $apiKey, $now, $now)
- ON CONFLICT(key_id) DO UPDATE SET
- api_key = $apiKey,
- updated_at = $now`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $apiKey: apiKey,
- $now: now,
- });
-}
-
-/**
- * Get a stored API key by key ID. Returns the key string or null.
- */
-export function getApiKey(keyId: string): string | null {
- const db = getDatabase();
- const row = db
- .query("SELECT api_key FROM api_keys WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { api_key: string } | null;
- return row?.api_key ?? null;
-}
-
-/**
- * Resolve an API key from the database, with env var fallback.
- * Pass the env var name (e.g. "GOOGLE_API_KEY") to check process.env as well.
- */
-export function resolveApiKey(keyId: string, envVar?: string): string | null {
- const dbKey = getApiKey(keyId);
- if (dbKey) return dbKey;
- if (envVar) return process.env[envVar] ?? null;
- return null;
-}
-
-/**
- * Delete a stored API key.
- */
-export function deleteApiKey(keyId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId });
-}
-
-/**
- * List all stored API keys with metadata (key value excluded for security).
- */
-export function listApiKeys(): Array<{
- keyId: string;
- provider: string;
- importedAt: number;
- updatedAt: number;
-}> {
- const db = getDatabase();
- const rows = db
- .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id")
- .all() as Array<Record<string, unknown>>;
- return rows.map((row) => ({
- keyId: row.key_id as string,
- provider: row.provider as string,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- }));
-}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
deleted file mode 100644
index 050a0fc..0000000
--- a/packages/core/src/credentials/claude.ts
+++ /dev/null
@@ -1,694 +0,0 @@
-import { createHash } from "node:crypto";
-import {
- chmodSync,
- existsSync,
- mkdirSync,
- readdirSync,
- readFileSync,
- writeFileSync,
-} from "node:fs";
-import { homedir } from "node:os";
-import { basename, dirname, join } from "node:path";
-import { getDatabase } from "../db/index.js";
-import { getAnthropicBetas } from "./anthropic-betas.js";
-import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
-
-// Re-exported for backward compatibility — `getAnthropicBetas` historically
-// lived here and is surfaced through `credentials/index.ts`. The definition
-// now lives in the dependency-free `anthropic-betas.ts` module.
-export { getAnthropicBetas };
-
-export interface ClaudeCredentials {
- accessToken: string;
- refreshToken: string;
- expiresAt: number;
- subscriptionType?: string;
-}
-
-export interface ClaudeAccount {
- id: string;
- label: string;
- source: string;
- credentials: ClaudeCredentials;
-}
-
-const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token";
-const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
-const CREDENTIAL_CACHE_TTL_MS = 30_000;
-
-const CREDENTIALS_DIR = join(homedir(), ".claude");
-const PRIMARY_CREDENTIALS_FILE = join(CREDENTIALS_DIR, ".credentials.json");
-
-const accountCacheMap = new Map<string, { creds: ClaudeCredentials; cachedAt: number }>();
-
-function parseCredentialsFile(raw: string): ClaudeCredentials | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return null;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
-
- const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
- const creds = data as Record<string, unknown>;
-
- if (
- (creds as Record<string, unknown>).mcpOAuth &&
- !(creds as Record<string, unknown>).accessToken
- ) {
- return null;
- }
-
- if (
- typeof creds.accessToken !== "string" ||
- typeof creds.refreshToken !== "string" ||
- typeof creds.expiresAt !== "number"
- ) {
- return null;
- }
-
- return {
- accessToken: creds.accessToken as string,
- refreshToken: creds.refreshToken as string,
- expiresAt: creds.expiresAt as number,
- subscriptionType:
- typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
- };
-}
-
-function readCredentialsFile(filePath: string): ClaudeCredentials | null {
- try {
- if (!existsSync(filePath)) return null;
- const raw = readFileSync(filePath, "utf-8").trim();
- if (!raw) return null;
- return parseCredentialsFile(raw);
- } catch {
- return null;
- }
-}
-
-function writeCredentialsFile(filePath: string, creds: ClaudeCredentials): void {
- let existing: Record<string, unknown> = {};
- try {
- if (existsSync(filePath)) {
- const raw = readFileSync(filePath, "utf-8").trim();
- if (raw) {
- existing = JSON.parse(raw);
- }
- }
- } catch {
- existing = {};
- }
-
- const hasWrapper = "claudeAiOauth" in existing;
- const target = hasWrapper ? (existing.claudeAiOauth as Record<string, unknown>) : existing;
- target.accessToken = creds.accessToken;
- target.refreshToken = creds.refreshToken;
- target.expiresAt = creds.expiresAt;
- if (creds.subscriptionType) {
- target.subscriptionType = creds.subscriptionType;
- }
-
- const dir = dirname(filePath);
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- }
- writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: "utf-8", mode: 0o600 });
- if (process.platform !== "win32") {
- chmodSync(filePath, 0o600);
- }
-}
-
-async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials | null> {
- const body = new URLSearchParams({
- grant_type: "refresh_token",
- client_id: OAUTH_CLIENT_ID,
- refresh_token: refreshToken,
- });
-
- try {
- const response = await fetch(OAUTH_TOKEN_URL, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: body.toString(),
- });
-
- if (!response.ok) {
- return null;
- }
-
- const data = (await response.json()) as Record<string, unknown>;
- if (!data.access_token || typeof data.access_token !== "string") {
- return null;
- }
-
- return {
- accessToken: data.access_token as string,
- refreshToken: (data.refresh_token as string) ?? refreshToken,
- expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000,
- subscriptionType:
- typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
- };
- } catch {
- return null;
- }
-}
-
-function buildAccountLabels(accounts: ClaudeAccount[]): void {
- for (const acct of accounts) {
- acct.label = acct.credentials.subscriptionType
- ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}`
- : "Claude";
- }
-}
-
-/**
- * Load Claude accounts from the SQLite database.
- * Returns accounts for all stored anthropic credentials.
- * This is the preferred path — file-based discovery is the fallback.
- */
-export function getClaudeAccountsFromDB(): ClaudeAccount[] {
- const stored = listStoredCredentials();
- const accounts: ClaudeAccount[] = [];
-
- for (const cred of stored) {
- if (cred.provider !== "anthropic") continue;
- accounts.push({
- id: cred.keyId,
- label: "",
- source: `db:${cred.keyId}`,
- credentials: {
- accessToken: cred.accessToken,
- refreshToken: cred.refreshToken,
- expiresAt: cred.expiresAt,
- subscriptionType: cred.subscriptionType ?? undefined,
- },
- });
- }
-
- buildAccountLabels(accounts);
- return accounts;
-}
-
-export function discoverClaudeAccounts(): ClaudeAccount[] {
- const accounts: ClaudeAccount[] = [];
-
- if (!existsSync(CREDENTIALS_DIR)) {
- return accounts;
- }
-
- const primaryCreds = readCredentialsFile(PRIMARY_CREDENTIALS_FILE);
- if (primaryCreds) {
- accounts.push({
- id: "claude-default",
- label: "",
- source: PRIMARY_CREDENTIALS_FILE,
- credentials: primaryCreds,
- });
- }
-
- try {
- const files = readdirSync(CREDENTIALS_DIR);
- const credFiles = files.filter(
- (f) => f.startsWith(".credentials") && f.endsWith(".json") && f !== ".credentials.json",
- );
- for (const file of credFiles) {
- const filePath = join(CREDENTIALS_DIR, file);
- const creds = readCredentialsFile(filePath);
- if (creds) {
- const id = basename(file, ".json").replace(/^\.credentials/, "claude") || `claude-${file}`;
- accounts.push({
- id,
- label: "",
- source: filePath,
- credentials: creds,
- });
- }
- }
- } catch {
- // ignore
- }
-
- buildAccountLabels(accounts);
- return accounts;
-}
-
-export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null {
- const cached = accountCacheMap.get(account.id);
- const now = Date.now();
- if (
- cached &&
- now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
- cached.creds.expiresAt > now + 60_000
- ) {
- return cached.creds;
- }
-
- // Re-read credentials: from DB for DB-backed accounts, from file otherwise
- if (account.source.startsWith("db:")) {
- const stored = getStoredCredentials(account.id);
- if (stored) {
- account.credentials = {
- accessToken: stored.accessToken,
- refreshToken: stored.refreshToken,
- expiresAt: stored.expiresAt,
- subscriptionType: stored.subscriptionType ?? undefined,
- };
- }
- } else {
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
- }
- }
-
- if (account.credentials.expiresAt > now + 60_000) {
- accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now });
- return account.credentials;
- }
-
- // Try OAuth refresh
- if (account.credentials.refreshToken) {
- // Synchronous refresh not available in this context, but the async version will be used
- // by getCredentialsForAccount below
- return null;
- }
-
- return null;
-}
-
-export async function refreshAccountCredentialsAsync(
- account: ClaudeAccount,
-): Promise<ClaudeCredentials | null> {
- const cached = accountCacheMap.get(account.id);
- const now = Date.now();
- if (
- cached &&
- now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
- cached.creds.expiresAt > now + 60_000
- ) {
- return cached.creds;
- }
-
- // Re-read credentials: from DB for DB-backed accounts, from file otherwise
- if (account.source.startsWith("db:")) {
- const stored = getStoredCredentials(account.id);
- if (stored) {
- account.credentials = {
- accessToken: stored.accessToken,
- refreshToken: stored.refreshToken,
- expiresAt: stored.expiresAt,
- subscriptionType: stored.subscriptionType ?? undefined,
- };
- }
- } else {
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
- }
- }
-
- if (account.credentials.expiresAt > now + 60_000) {
- accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now });
- return account.credentials;
- }
-
- // Try OAuth refresh
- if (account.credentials.refreshToken) {
- const refreshed = await refreshViaOAuth(account.credentials.refreshToken);
- if (refreshed && refreshed.expiresAt > now + 60_000) {
- account.credentials = refreshed;
- // Update DB if this is a DB-backed account, otherwise write to file
- if (account.source.startsWith("db:")) {
- updateStoredTokens(
- account.id,
- refreshed.accessToken,
- refreshed.refreshToken,
- refreshed.expiresAt,
- );
- } else {
- writeCredentialsFile(account.source, refreshed);
- }
- accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now });
- return refreshed;
- }
- }
-
- return null;
-}
-
-// ─── Billing Header Computation ────────────────────────────────
-
-const BILLING_SALT = "59cf53e54c78";
-const CC_VERSION = "2.1.112";
-
-function extractFirstUserMessageText(messages: Array<{ role: string; content: string }>): string {
- const userMsg = messages.find((m) => m.role === "user");
- if (!userMsg) return "";
- if (typeof userMsg.content === "string") return userMsg.content;
- return "";
-}
-
-function computeCch(messageText: string): string {
- return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
-}
-
-function computeVersionSuffix(messageText: string, version: string): string {
- const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join("");
- const input = `${BILLING_SALT}${sampled}${version}`;
- return createHash("sha256").update(input).digest("hex").slice(0, 3);
-}
-
-export function buildBillingHeaderValue(
- messages: Array<{ role: string; content: string }>,
-): string {
- const text = extractFirstUserMessageText(messages);
- const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION;
- const suffix = computeVersionSuffix(text, version);
- const cch = computeCch(text);
- return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${cch};`;
-}
-
-export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-
-/**
- * Build the request body for a Claude "wake" probe — a tiny, cheap message
- * whose only purpose is to keep the subscription's rate-limit window warm.
- *
- * This MUST mirror the shape of a genuine Claude Code CLI request, because
- * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated,
- * Claude-Code-billed requests. A bare `{ model, messages }` body (no system
- * identity) is rejected (401/403) — which is exactly how the old probe silently
- * failed. The valid shape is:
- *
- * system: [
- * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control
- * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
- * ]
- * messages: [ { role: "user", content: "hi" } ]
- *
- * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user
- * turn. Pure: deterministic given its inputs (the billing header samples only
- * the user text), so it can be unit-tested without touching the network.
- */
-export function buildWakeProbeBody(model: string): {
- model: string;
- max_tokens: number;
- system: Array<{ type: "text"; text: string }>;
- messages: Array<{ role: "user"; content: string }>;
-} {
- const messages = [{ role: "user" as const, content: "hi" }];
- return {
- model,
- max_tokens: 16,
- system: [
- { type: "text", text: buildBillingHeaderValue(messages) },
- { type: "text", text: SYSTEM_IDENTITY },
- ],
- messages,
- };
-}
-
-// ─── Anthropic Request Headers ────────────────────────────────
-
-export function getAnthropicHeaders(accessToken: string): Record<string, string> {
- return {
- authorization: `Bearer ${accessToken}`,
- "anthropic-version": "2023-06-01",
- "anthropic-beta": getAnthropicBetas().join(","),
- "anthropic-dangerous-direct-browser-access": "true",
- "x-app": "cli",
- "user-agent": `claude-cli/${CC_VERSION} (external, sdk-cli)`,
- };
-}
-
-// ─── Usage Tracking ───────────────────────────────────────────
-
-export interface ClaudeUsageBucket {
- utilization?: number;
- resetsAt?: number;
-}
-
-export interface ClaudeUsageReport {
- fiveHour?: ClaudeUsageBucket;
- sevenDay?: ClaudeUsageBucket;
- sevenDayOpus?: ClaudeUsageBucket;
- sevenDaySonnet?: ClaudeUsageBucket;
- accountId?: string;
- email?: string;
- orgId?: string;
-}
-
-/**
- * A usage report paired with provenance: whether it came back from a fresh
- * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served
- * from the local `usage_cache` table after a failed/skipped live fetch.
- *
- * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when
- * that cached payload was last fetched FROM the source (the `usage_cache.cached_at`
- * column). `source: "live"` omits `cachedAt` (the data is current as of now).
- */
-export interface ClaudeUsageResult {
- report: ClaudeUsageReport;
- source: "live" | "cache";
- /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */
- cachedAt?: number;
-}
-
-// ─── Well-known Anthropic models ──────────────────────────────
-
-/**
- * Fetch the live list of available models from Anthropic's /v1/models endpoint.
- * Requires valid OAuth credentials with anthropic-beta headers.
- */
-export async function fetchAnthropicModels(accessToken: string): Promise<string[]> {
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(accessToken),
- accept: "application/json",
- };
-
- try {
- const response = await fetch("https://api.anthropic.com/v1/models", { headers });
- if (!response.ok) {
- console.warn(`dispatch: Anthropic /v1/models returned ${response.status}`);
- return [];
- }
-
- const data = (await response.json()) as {
- data?: Array<{ id: string }>;
- models?: Array<{ id: string }>;
- };
- const entries = data.data ?? data.models ?? [];
- return entries.map((m) => m.id).filter(Boolean);
- } catch (err) {
- console.warn(
- `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`,
- );
- return [];
- }
-}
-
-/** Fallback list if /v1/models is unreachable. */
-export const ANTHROPIC_MODELS_FALLBACK = [
- "claude-sonnet-4-20250514",
- "claude-opus-4-20250514",
- "claude-3.5-sonnet-20241022",
- "claude-3.5-haiku-20241022",
- "claude-3-opus-20240229",
-];
-
-/**
- * Pick the model to use for a Claude "wake" probe from a list of model ids.
- *
- * The probe only needs a small/cheap model to register activity against the
- * subscription, so we target Haiku. Model ids change over time (the old
- * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the
- * caller fetches the live list from `/v1/models` and we resolve by substring.
- *
- * Selection: the FIRST id whose name contains "haiku" (case-insensitive).
- * Anthropic's `/v1/models` returns models newest-first, so first-match
- * naturally prefers the newest Haiku. Returns `null` when nothing matches so
- * the caller can surface a clear error instead of probing an invalid model.
- */
-export function selectHaikuModel(models: string[]): string | null {
- return models.find((id) => id.toLowerCase().includes("haiku")) ?? null;
-}
-
-// ─── Credential Validation ────────────────────────────────────
-
-export interface ClaudeProfile {
- accountId?: string;
- email?: string;
- subscriptionType?: string;
-}
-
-/**
- * Validate that Claude credentials are usable by hitting the OAuth profile endpoint.
- * Returns the profile info if valid, or null if the token is dead.
- */
-export async function validateAccountCredentials(
- account: ClaudeAccount,
-): Promise<ClaudeProfile | null> {
- const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return null;
-
- const url = "https://api.anthropic.com/api/oauth/profile";
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(creds.accessToken),
- accept: "application/json, text/plain, */*",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const data = (await response.json()) as Record<string, unknown>;
-
- const profile: ClaudeProfile = {};
- const uuid = typeof data.uuid === "string" ? data.uuid : undefined;
- const email = typeof data.email === "string" ? data.email : undefined;
-
- if (uuid) profile.accountId = uuid;
- if (email) profile.email = email;
-
- // subscriptionType comes from the credentials file, but profile may also carry it
- profile.subscriptionType = account.credentials.subscriptionType;
-
- return profile;
- } catch {
- return null;
- }
-}
-
-async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport | null> {
- const url = "https://api.anthropic.com/api/oauth/usage";
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(accessToken),
- accept: "application/json, text/plain, */*",
- "content-type": "application/json",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
- const data = (await response.json()) as Record<string, unknown>;
-
- const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => {
- if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined;
- const b = bucket as Record<string, unknown>;
- // API returns utilization as 0-100 percentage; normalize to 0-1 fraction
- const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined;
- const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined;
- const resetsAt =
- typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
- if (utilization === undefined && resetsAt === undefined) return undefined;
- return { utilization, resetsAt };
- };
-
- const report: ClaudeUsageReport = {
- fiveHour: parseBucket(data.five_hour),
- sevenDay: parseBucket(data.seven_day),
- sevenDayOpus: parseBucket(data.seven_day_opus),
- sevenDaySonnet: parseBucket(data.seven_day_sonnet),
- };
-
- if (orgId) report.orgId = orgId;
-
- // Try to extract identity
- const accountId =
- typeof data.account_id === "string"
- ? data.account_id
- : typeof data.user_id === "string"
- ? data.user_id
- : typeof data.org_id === "string"
- ? data.org_id
- : undefined;
- if (accountId) report.accountId = accountId;
-
- const email = typeof data.email === "string" ? data.email : undefined;
- if (email) report.email = email;
-
- return report;
- } catch {
- return null;
- }
-}
-
-/**
- * Read a cached usage report plus the epoch-ms it was last fetched from source.
- * Returns `null` when there is no cached row (or on any DB/parse error).
- */
-function getCachedUsageWithMeta(
- keyId: string,
-): { report: ClaudeUsageReport; cachedAt: number } | null {
- try {
- const db = getDatabase();
- const row = db
- .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null;
- if (!row) return null;
- return {
- report: JSON.parse(row.report_json) as ClaudeUsageReport,
- cachedAt: row.cached_at,
- };
- } catch {
- return null;
- }
-}
-
-function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void {
- try {
- const db = getDatabase();
- db.query(
- `INSERT INTO usage_cache (key_id, provider, cached_at, report_json)
- VALUES ($keyId, $provider, $cachedAt, $reportJson)
- ON CONFLICT(key_id) DO UPDATE SET
- cached_at = $cachedAt,
- report_json = $reportJson`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $cachedAt: Date.now(),
- $reportJson: JSON.stringify(report),
- });
- } catch {
- // Ignore DB errors
- }
-}
-
-/**
- * Fetch an account's usage report along with its provenance (live vs cache).
- *
- * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint;
- * on success the fresh report is cached and returned as `source: "live"`. If
- * credentials cannot be refreshed OR the live fetch returns nothing, fall back
- * to the local `usage_cache` row and return it as `source: "cache"` with the
- * `cachedAt` timestamp recording when that payload was last fetched from source.
- * Returns `null` only when neither a live report nor a cached row is available.
- */
-export async function getAccountUsageWithSource(
- account: ClaudeAccount,
-): Promise<ClaudeUsageResult | null> {
- const creds = await refreshAccountCredentialsAsync(account);
- if (creds) {
- const report = await fetchClaudeUsage(creds.accessToken);
- if (report) {
- setCachedUsage(account.id, "anthropic", report);
- return { report, source: "live" };
- }
- }
- const cached = getCachedUsageWithMeta(account.id);
- if (cached) {
- return { report: cached.report, source: "cache", cachedAt: cached.cachedAt };
- }
- return null;
-}
-
-export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
- const result = await getAccountUsageWithSource(account);
- return result?.report ?? null;
-}
diff --git a/packages/core/src/credentials/copilot.ts b/packages/core/src/credentials/copilot.ts
deleted file mode 100644
index 8baf6f4..0000000
--- a/packages/core/src/credentials/copilot.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-// ─── GitHub Copilot Usage Tracking ───────────────────────────
-// Uses the internal GitHub Copilot user endpoint (same one the
-// official VS Code Copilot extension calls).
-
-export interface CopilotUsageReport {
- tokensConsumed?: number;
- tokensRemaining?: number;
- percentUsed?: number; // 0-100
- resetAt?: number; // Unix timestamp ms
- plan?: string;
-}
-
-export async function fetchCopilotUsage(
- token: string,
- _baseUrl: string,
-): Promise<CopilotUsageReport | null> {
- const url = "https://api.github.com/copilot_internal/user";
- const headers: Record<string, string> = {
- authorization: `Bearer ${token}`,
- accept: "application/json",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const data = (await response.json()) as Record<string, unknown>;
- const plan = typeof data.copilot_plan === "string" ? data.copilot_plan : undefined;
-
- const resetDate = typeof data.quota_reset_date === "string" ? data.quota_reset_date : undefined;
- const resetAt = resetDate ? Date.parse(resetDate) : undefined;
-
- const qs = data.quota_snapshots as Record<string, unknown> | undefined;
- const pi = qs?.premium_interactions as Record<string, unknown> | undefined;
-
- const entitlement = typeof pi?.entitlement === "number" ? pi.entitlement : undefined;
- const remaining = typeof pi?.remaining === "number" ? pi.remaining : undefined;
- const percentRemaining =
- typeof pi?.percent_remaining === "number" ? pi.percent_remaining : undefined;
-
- if (entitlement === undefined && remaining === undefined) {
- return null;
- }
-
- const tokensConsumed =
- entitlement !== undefined && remaining !== undefined ? entitlement - remaining : undefined;
- const percentUsed =
- percentRemaining !== undefined
- ? Math.round((100 - percentRemaining) * 100) / 100
- : tokensConsumed !== undefined && entitlement !== undefined && entitlement > 0
- ? Math.round((tokensConsumed / entitlement) * 10000) / 100
- : undefined;
-
- return {
- tokensConsumed,
- tokensRemaining: remaining,
- percentUsed,
- resetAt: resetAt && !Number.isNaN(resetAt) ? resetAt : undefined,
- plan,
- };
- } catch {
- return null;
- }
-}
diff --git a/packages/core/src/credentials/google.ts b/packages/core/src/credentials/google.ts
deleted file mode 100644
index 17bc930..0000000
--- a/packages/core/src/credentials/google.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-// ─── Google Gemini Usage Tracking ───────────────────────────
-// Two modes:
-// 1. API key → queries native Gemini models endpoint for rate limits
-// 2. Cookie → scrapes gemini.google.com for current usage % (like OpenCode)
-//
-// Set GEMINI_COOKIE env var with the value of your __Secure-1PSID cookie
-// from gemini.google.com to see current usage percentages.
-
-export interface GoogleUsageBucket {
- percentUsed: number; // 0-100
- resetsAt?: string; // e.g. "22:40" or "30 May at 17:40"
-}
-
-export interface GoogleUsageReport {
- // Mode 1: API key rate limits
- models?: Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
- }>;
- // Mode 2: Cookie-scraped usage from gemini.google.com
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
-}
-
-// Helpers to extract HTML text between markers
-function extractBetween(html: string, after: string, before: string): string | null {
- const start = html.indexOf(after);
- if (start === -1) return null;
- const s = start + after.length;
- const end = html.indexOf(before, s);
- if (end === -1) return null;
- return html.slice(s, end).trim();
-}
-
-function extractPercent(html: string, afterMarker: string): number | null {
- const chunk = extractBetween(html, afterMarker, "%");
- if (!chunk) return null;
- const digits = chunk.replace(/\D/g, "");
- const n = parseInt(digits, 10);
- return Number.isNaN(n) ? null : n;
-}
-
-function extractResetTime(html: string, afterMarker: string): string | null {
- // Look for "Resets at HH:MM" or "Resets on DD Mon at HH:MM"
- const start = html.indexOf(afterMarker);
- if (start === -1) return null;
- const chunk = html.slice(start + afterMarker.length);
- // Match time patterns
- const m = chunk.match(/Resets?\s+(at|on)\s+([^<]+)/i);
- if (!m?.[1] || !m[2]) return null;
- return `${m[1]} ${m[2].trim()}`;
-}
-
-async function scrapeGeminiWeb(cookie: string): Promise<{
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
-} | null> {
- try {
- const response = await fetch("https://gemini.google.com/app", {
- headers: {
- cookie: `__Secure-1PSID=${cookie}`,
- "accept-language": "en-US,en;q=0.9",
- },
- redirect: "follow",
- });
-
- if (!response.ok) return null;
-
- const html = await response.text();
-
- // Detect auth redirect
- if (html.includes("ServiceLogin") || html.includes("sign in")) {
- return null;
- }
-
- // Look for usage data in the page
- // Pattern: "Current usage" followed by a percentage and reset time
- const currentPct = extractPercent(html, "Current usage");
- const currentReset = extractResetTime(html, "Current usage");
-
- // Weekly limit section
- const weeklyPct = extractPercent(html, "Weekly limit");
- const weeklyReset = extractResetTime(html, "Weekly limit");
-
- const result: {
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
- } = {};
-
- if (currentPct !== null) {
- result.currentUsage = {
- percentUsed: currentPct,
- resetsAt: currentReset ?? undefined,
- };
- }
-
- if (weeklyPct !== null) {
- result.weeklyUsage = {
- percentUsed: weeklyPct,
- resetsAt: weeklyReset ?? undefined,
- };
- }
-
- if (result.currentUsage || result.weeklyUsage) return result;
- return null;
- } catch {
- return null;
- }
-}
-
-async function fetchModelsViaApiKey(apiKey: string): Promise<Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
-}> | null> {
- const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
-
- try {
- const response = await fetch(url);
- if (!response.ok) return null;
-
- const data = (await response.json()) as {
- models?: Array<{
- name: string;
- inputTokenLimit?: number;
- outputTokenLimit?: number;
- rateLimit?: { requestsPerMinute?: number };
- limits?: { requestsPerDay?: number };
- }>;
- };
-
- return (data.models ?? [])
- .filter((m) => {
- const name = m.name?.replace(/^models\//, "") ?? "";
- return name.startsWith("gemini-");
- })
- .map((m) => ({
- name: m.name?.replace(/^models\//, "") ?? "",
- inputTokenLimit: m.inputTokenLimit ?? 0,
- outputTokenLimit: m.outputTokenLimit ?? 0,
- rpm: m.rateLimit?.requestsPerMinute ?? 0,
- requestsPerDay: m.limits?.requestsPerDay ?? 0,
- }));
- } catch {
- return null;
- }
-}
-
-export async function fetchGoogleUsage(
- apiKey: string,
- _baseUrl: string,
-): Promise<GoogleUsageReport | null> {
- const results: GoogleUsageReport = {};
-
- // Try API key mode: get model rate limits
- const models = await fetchModelsViaApiKey(apiKey);
- if (models) {
- results.models = models;
- }
-
- // Try cookie mode: scrape gemini.google.com usage
- const cookie = process.env.GEMINI_COOKIE;
- if (cookie) {
- const scraped = await scrapeGeminiWeb(cookie);
- if (scraped) {
- if (scraped.currentUsage) results.currentUsage = scraped.currentUsage;
- if (scraped.weeklyUsage) results.weeklyUsage = scraped.weeklyUsage;
- }
- }
-
- if (results.models || results.currentUsage || results.weeklyUsage) return results;
- return null;
-}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
deleted file mode 100644
index 131f035..0000000
--- a/packages/core/src/credentials/index.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-export {
- deleteApiKey,
- getApiKey,
- listApiKeys,
- resolveApiKey,
- type StoredApiKey,
- setApiKey,
-} from "./api-keys.js";
-export {
- ANTHROPIC_MODELS_FALLBACK,
- buildBillingHeaderValue,
- buildWakeProbeBody,
- type ClaudeAccount,
- type ClaudeCredentials,
- type ClaudeProfile,
- type ClaudeUsageBucket,
- type ClaudeUsageReport,
- type ClaudeUsageResult,
- discoverClaudeAccounts,
- fetchAnthropicModels,
- getAccountUsage,
- getAccountUsageWithSource,
- getAnthropicBetas,
- getAnthropicHeaders,
- getClaudeAccountsFromDB,
- refreshAccountCredentials,
- refreshAccountCredentialsAsync,
- SYSTEM_IDENTITY,
- selectHaikuModel,
- validateAccountCredentials,
-} from "./claude.js";
-export {
- type CopilotUsageReport,
- fetchCopilotUsage,
-} from "./copilot.js";
-export {
- fetchGoogleUsage,
- type GoogleUsageReport,
-} from "./google.js";
-export {
- fetchOpencodeUsage,
- type OpencodeUsageBucket,
- type OpencodeUsageReport,
-} from "./opencode.js";
-export {
- deleteStoredCredentials,
- getStoredCredentials,
- importCredentialsFromFile,
- listStoredCredentials,
- type StoredCredential,
- updateStoredTokens,
-} from "./store.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
deleted file mode 100644
index d4d4851..0000000
--- a/packages/core/src/credentials/opencode.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { resolveApiKey } from "./api-keys.js";
-
-// ─── OpenCode Usage Tracking ──────────────────────────────────
-// OpenCode has no public usage API. We scrape usage from the
-// SolidStart SSR-rendered workspace page using a session cookie.
-// Requires OPENCODE_COOKIE env var.
-// Workspace IDs: OPENCODE_WS1_ID for opencode-1, OPENCODE_WS2_ID for opencode-2.
-
-export interface OpencodeUsageBucket {
- utilization?: number; // 0-1 fraction
- resetsAt?: number; // Unix timestamp ms
-}
-
-export interface OpencodeUsageReport {
- fiveHour?: OpencodeUsageBucket;
- weekly?: OpencodeUsageBucket;
- monthly?: OpencodeUsageBucket;
-}
-
-function getWorkspaceId(keyId: string): string | undefined {
- // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws"
- const match = keyId.match(/opencode-(\d+)$/i);
- if (match) {
- const num = match[1];
- const specific = resolveApiKey(`opencode-ws${num}`);
- if (specific) return specific;
- }
- return resolveApiKey("opencode-ws") ?? undefined;
-}
-
-function parseOcDouble(html: string, key: string): number {
- const idx = html.indexOf(`${key}:`);
- if (idx === -1) return 0;
- let start = idx + key.length + 1;
- while (start < html.length && html[start] === " ") start++;
- let end = start;
- while (end < html.length && html[end] !== "," && html[end] !== "}") {
- end++;
- }
- const val = parseFloat(html.slice(start, end));
- return Number.isNaN(val) ? 0 : val;
-}
-
-function parseOcInt(html: string, key: string): number {
- const idx = html.indexOf(`${key}:`);
- if (idx === -1) return 0;
- let i = idx + key.length + 1;
- while (i < html.length && html[i] === " ") i++;
- return parseInt(html.slice(i), 10) || 0;
-}
-
-function parseOcBucket(
- html: string,
- bucketName: string,
-): { utilization: number; resetsAt: number } | null {
- const search = `${bucketName}:`;
- const pos = html.indexOf(search);
- if (pos === -1) return null;
-
- // Find the opening brace after the bucket name
- const brace = html.indexOf("{", pos);
- if (brace === -1) return null;
-
- const resetSecs = parseOcInt(html.slice(brace), "resetInSec");
- const usagePct = parseOcDouble(html.slice(brace), "usagePercent");
-
- const utilization = usagePct / 100; // convert 0-100% to 0-1 fraction
- const resetsAt = Date.now() + resetSecs * 1000;
-
- return { utilization, resetsAt };
-}
-
-export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> {
- const cookie = resolveApiKey("opencode-cookie");
- const wsId = getWorkspaceId(keyId);
-
- if (!cookie || !wsId) {
- return null;
- }
-
- const url = `https://opencode.ai/workspace/${encodeURIComponent(wsId)}/go`;
-
- try {
- const response = await fetch(url, {
- headers: {
- accept: "text/html",
- cookie: `auth=${cookie}`,
- },
- redirect: "follow",
- });
-
- if (!response.ok) return null;
-
- const html = await response.text();
-
- // Auth redirect check
- if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) {
- return null;
- }
-
- // Find the lite.subscription data block.
- // HTML contains: lite.subscription.get[\"<wsId>\"]
- // We need literal backslashes; use \x5c (hex for backslash).
- const wsKey = `lite.subscription.get[\x5c"${wsId}\x5c"]`;
- const wsPos = html.indexOf(wsKey);
- if (wsPos === -1) return null;
-
- // Search for the resolved data starting from the ws key position
- const minePos = html.indexOf("mine:", wsPos);
- const slice = minePos !== -1 ? html.slice(minePos) : "";
-
- const fiveHour = parseOcBucket(slice, "rollingUsage");
- const weekly = parseOcBucket(slice, "weeklyUsage");
- const monthly = parseOcBucket(slice, "monthlyUsage");
-
- if (!fiveHour && !weekly && !monthly) return null;
-
- return {
- fiveHour: fiveHour ?? undefined,
- weekly: weekly ?? undefined,
- monthly: monthly ?? undefined,
- };
- } catch {
- return null;
- }
-}
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
deleted file mode 100644
index 662b322..0000000
--- a/packages/core/src/credentials/store.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { existsSync, readFileSync } from "node:fs";
-import { getDatabase } from "../db/index.js";
-import type { ClaudeCredentials } from "./claude.js";
-
-export interface StoredCredential {
- keyId: string;
- provider: string;
- accessToken: string;
- refreshToken: string;
- expiresAt: number;
- subscriptionType: string | null;
- sourceFile: string | null;
- importedAt: number;
- updatedAt: number;
-}
-
-function parseCredentialsFile(raw: string): ClaudeCredentials | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return null;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
-
- const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
- const creds = data as Record<string, unknown>;
-
- if (creds.mcpOAuth && !creds.accessToken) return null;
-
- if (
- typeof creds.accessToken !== "string" ||
- typeof creds.refreshToken !== "string" ||
- typeof creds.expiresAt !== "number"
- ) {
- return null;
- }
-
- return {
- accessToken: creds.accessToken as string,
- refreshToken: creds.refreshToken as string,
- expiresAt: creds.expiresAt as number,
- subscriptionType:
- typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
- };
-}
-
-/**
- * Import credentials from a file into the database for a specific key.
- * Reads the credential file, parses it, and upserts into the credentials table.
- */
-export function importCredentialsFromFile(
- keyId: string,
- provider: string,
- filePath: string,
-): { success: boolean; error?: string } {
- if (!existsSync(filePath)) {
- return { success: false, error: `File not found: ${filePath}` };
- }
-
- let raw: string;
- try {
- raw = readFileSync(filePath, "utf-8").trim();
- } catch (e) {
- return {
- success: false,
- error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`,
- };
- }
-
- if (!raw) {
- return { success: false, error: "File is empty" };
- }
-
- const creds = parseCredentialsFile(raw);
- if (!creds) {
- return { success: false, error: "Invalid credentials format" };
- }
-
- const db = getDatabase();
- const now = Date.now();
-
- db.query(
- `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at)
- VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now)
- ON CONFLICT(key_id) DO UPDATE SET
- access_token = $accessToken,
- refresh_token = $refreshToken,
- expires_at = $expiresAt,
- subscription_type = $subscriptionType,
- source_file = $sourceFile,
- updated_at = $now`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $accessToken: creds.accessToken,
- $refreshToken: creds.refreshToken,
- $expiresAt: creds.expiresAt,
- $subscriptionType: creds.subscriptionType ?? null,
- $sourceFile: filePath,
- $now: now,
- });
-
- return { success: true };
-}
-
-/**
- * Get stored credentials for a specific key from the database.
- */
-export function getStoredCredentials(keyId: string): StoredCredential | null {
- const db = getDatabase();
- const row = db
- .query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
- )
- .get({ $keyId: keyId }) as Record<string, unknown> | null;
-
- if (!row) return null;
-
- return {
- keyId: row.key_id as string,
- provider: row.provider as string,
- accessToken: row.access_token as string,
- refreshToken: row.refresh_token as string,
- expiresAt: row.expires_at as number,
- subscriptionType: row.subscription_type as string | null,
- sourceFile: row.source_file as string | null,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-/**
- * Update tokens in the database after a refresh.
- */
-export function updateStoredTokens(
- keyId: string,
- accessToken: string,
- refreshToken: string,
- expiresAt: number,
-): void {
- const db = getDatabase();
- db.query(
- `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`,
- ).run({
- $keyId: keyId,
- $accessToken: accessToken,
- $refreshToken: refreshToken,
- $expiresAt: expiresAt,
- $now: Date.now(),
- });
-}
-
-/**
- * Delete stored credentials for a key.
- */
-export function deleteStoredCredentials(keyId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId });
-}
-
-/**
- * List all keys that have imported credentials, with their status.
- */
-export function listStoredCredentials(): StoredCredential[] {
- const db = getDatabase();
- const rows = db
- .query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
- )
- .all() as Array<Record<string, unknown>>;
-
- return rows.map((row) => ({
- keyId: row.key_id as string,
- provider: row.provider as string,
- accessToken: row.access_token as string,
- refreshToken: row.refresh_token as string,
- expiresAt: row.expires_at as number,
- subscriptionType: row.subscription_type as string | null,
- sourceFile: row.source_file as string | null,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- }));
-}
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts
deleted file mode 100644
index b434a47..0000000
--- a/packages/core/src/db/chunks.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { randomUUID } from "node:crypto";
-import {
- explodeTurn,
- explodeUserText,
- groupRowsToMessages,
- type MessageRow,
-} from "../chunks/transform.js";
-import type {
- ChunkData,
- ChunkRow,
- ChunkRowDraft,
- TextData,
- UsageData,
- UsageStats,
-} from "../types/index.js";
-import { getDatabase } from "./index.js";
-
-// Re-export the DB-free transforms so existing barrel consumers
-// (`@dispatch/core`) keep importing them from here. The browser frontend deep-
-// imports them directly from `chunks/transform.js` to avoid the DB dependency.
-export { explodeTurn, explodeUserText, groupRowsToMessages, type MessageRow };
-
-// ─── Persistence ─────────────────────────────────────────────────
-
-function mapRow(row: Record<string, unknown>): ChunkRow {
- let data: ChunkData;
- try {
- data = JSON.parse(row.data_json as string) as ChunkData;
- } catch {
- data = { text: "" } as TextData;
- }
- return {
- id: row.id as string,
- tabId: row.tab_id as string,
- seq: row.seq as number,
- turnId: row.turn_id as string,
- step: row.step as number,
- role: row.role as ChunkRow["role"],
- type: row.type as ChunkRow["type"],
- data,
- createdAt: row.created_at as number,
- };
-}
-
-/**
- * Append one or more chunk-row drafts to a tab, assigning a monotonic per-tab
- * `seq` and a fresh id/timestamp to each. Returns the inserted rows in order.
- */
-export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] {
- if (drafts.length === 0) return [];
- const db = getDatabase();
- const maxSeq = db
- .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId")
- .get({ $tabId: tabId }) as { max_seq: number };
- let seq = (maxSeq?.max_seq ?? -1) + 1;
- const now = Date.now();
- const insert = db.query(
- `INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at)
- VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`,
- );
- const out: ChunkRow[] = [];
- // Wrap the whole batch in one transaction: a turn's chunks are persisted in
- // a single `appendChunks` call, so this is one fsync per turn instead of one
- // per row — the chosen low-IO write strategy for constrained backends.
- const insertAll = db.transaction(() => {
- for (const draft of drafts) {
- const id = randomUUID();
- insert.run({
- $id: id,
- $tabId: tabId,
- $seq: seq,
- $turnId: draft.turnId,
- $step: draft.step,
- $role: draft.role,
- $type: draft.type,
- $dataJson: JSON.stringify(draft.data),
- $now: now,
- });
- out.push({
- id,
- tabId,
- seq,
- turnId: draft.turnId,
- step: draft.step,
- role: draft.role,
- type: draft.type,
- data: draft.data,
- createdAt: now,
- });
- seq++;
- }
- });
- insertAll();
- return out;
-}
-
-/**
- * Read chunk rows for a tab in `seq` order (ASC). Pagination mirrors the old
- * message pagination but at chunk granularity:
- * - no options → all rows;
- * - `before` → rows with `seq < before`, most-recent-first then reversed;
- * - `limit` → most recent `limit` rows, reversed to ASC.
- */
-export function getChunksForTab(
- tabId: string,
- options?: { limit?: number; before?: number },
-): ChunkRow[] {
- const db = getDatabase();
- if (!options) {
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
- }
- const { limit, before } = options;
- if (before !== undefined) {
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC",
- )
- .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
-}
-
-/**
- * Derived, grouped view of a tab's full history as messages. Used to
- * pre-populate the agent's in-memory `ChatMessage[]` history when an Agent is
- * (re)constructed. Always reads the full log (grouping a partial window would
- * be lossy for the rebuild path).
- */
-export function getMessagesForTab(tabId: string): MessageRow[] {
- return groupRowsToMessages(getChunksForTab(tabId));
-}
-
-export function getTotalChunkCount(tabId: string): number {
- const db = getDatabase();
- const row = db
- .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'")
- .get({ $tabId: tabId }) as { count: number } | null;
- return row?.count ?? 0;
-}
-
-/**
- * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows.
- *
- * Usage rows are written as an invisible side channel (one row per `usage`
- * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`,
- * so this aggregate is the read path. Because it sums server-side over every
- * row, it stays complete even after the frontend evicts/pages out old turns
- * (eviction is in-memory only). The return shape is structurally identical to
- * the frontend `CacheStats`, so reload can seed it directly.
- *
- * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`
- * = SUM over all usage rows;
- * - `requests` = COUNT of usage rows;
- * - `last` = the highest-seq usage row's split (most recent request);
- * - `null` when the tab has no usage rows.
- *
- * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on
- * `json_extract` over the freeform `data_json`.
- */
-export function getUsageStatsForTab(tabId: string): UsageStats | null {
- const db = getDatabase();
- const rows = db
- .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<{ data_json: string }>;
- if (rows.length === 0) return null;
-
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
- let last: UsageData | null = null;
- for (const row of rows) {
- let u: UsageData;
- try {
- u = JSON.parse(row.data_json) as UsageData;
- } catch {
- continue;
- }
- inputTokens += u.inputTokens ?? 0;
- outputTokens += u.outputTokens ?? 0;
- cacheReadTokens += u.cacheReadTokens ?? 0;
- cacheWriteTokens += u.cacheWriteTokens ?? 0;
- last = {
- inputTokens: u.inputTokens ?? 0,
- outputTokens: u.outputTokens ?? 0,
- cacheReadTokens: u.cacheReadTokens ?? 0,
- cacheWriteTokens: u.cacheWriteTokens ?? 0,
- };
- }
-
- return {
- inputTokens,
- outputTokens,
- cacheReadTokens,
- cacheWriteTokens,
- requests: rows.length,
- last,
- };
-}
-
-export function clearChunksForTab(tabId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId });
-}
-
-/**
- * Relocate every chunk row from one tab to another (compaction backup path).
- *
- * Used by conversation compaction to move the FULL pre-compaction history off
- * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id
- * (`toTabId`), leaving the canonical id free to be re-seeded with the summary +
- * preserved tail. `seq` values are preserved (they remain per-tab monotonic for
- * the destination since it starts empty), as are turn ids, so the relocated
- * history groups identically under its new tab. Returns the number of rows
- * moved.
- */
-export function rekeyChunks(fromTabId: string, toTabId: string): number {
- const db = getDatabase();
- const result = db
- .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from")
- .run({ $from: fromTabId, $to: toTabId });
- return Number(result.changes ?? 0);
-}
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
deleted file mode 100644
index 93ec1f9..0000000
--- a/packages/core/src/db/index.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { Database } from "bun:sqlite";
-import { existsSync, mkdirSync } from "node:fs";
-import { homedir } from "node:os";
-import { isAbsolute, join } from "node:path";
-
-/**
- * Returns the directory for persistent Dispatch data, following XDG Base
- * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to
- * `~/.local/share/dispatch`).
- */
-function getDataDir(): string {
- const xdg = process.env.XDG_DATA_HOME;
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share");
- return join(base, "dispatch");
-}
-
-let _db: Database | null = null;
-
-/**
- * Get (or create) the singleton SQLite database.
- *
- * - Creates the data directory if it doesn't exist.
- * - Creates `dispatch.db` if it doesn't exist.
- * - Enables WAL journal mode for concurrent read performance.
- */
-export function getDatabase(): Database {
- if (_db) return _db;
-
- const dir = getDataDir();
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true });
- }
-
- const dbPath = join(dir, "dispatch.db");
- _db = new Database(dbPath, { create: true });
-
- // WAL mode: better concurrent read performance, safe for single-writer
- _db.run("PRAGMA journal_mode = WAL;");
- // Recommended for WAL: normal synchronous is safe and faster
- _db.run("PRAGMA synchronous = NORMAL;");
- // Enable foreign keys
- _db.run("PRAGMA foreign_keys = ON;");
-
- // Create tables
- _db.run(`CREATE TABLE IF NOT EXISTS credentials (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- access_token TEXT NOT NULL,
- refresh_token TEXT NOT NULL,
- expires_at INTEGER NOT NULL,
- subscription_type TEXT,
- source_file TEXT,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe
- // slot). The PK is (hour, slot_minute). Destructive migration off the legacy
- // single-row-per-hour schema: detect by absence of the `slot_minute` column
- // and drop the old table. Other tables (credentials, api_keys, usage_cache,
- // settings, tabs, chunks) are NOT touched.
- const legacyWakeSchema = (() => {
- try {
- const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>;
- if (cols.length === 0) return false; // table doesn't exist yet
- return !cols.some((c) => c.name === "slot_minute");
- } catch {
- return false;
- }
- })();
- if (legacyWakeSchema) {
- _db.run("DROP TABLE IF EXISTS wake_schedule");
- }
- _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
- hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
- slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)),
- next_wake_at INTEGER NOT NULL,
- PRIMARY KEY (hour, slot_minute)
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- cached_at INTEGER NOT NULL,
- report_json TEXT NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS api_keys (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- api_key TEXT NOT NULL,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS tabs (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- key_id TEXT,
- model_id TEXT,
- parent_tab_id TEXT,
- status TEXT NOT NULL DEFAULT 'idle',
- is_open INTEGER NOT NULL DEFAULT 1,
- position INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- try {
- _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT");
- } catch {
- // Column already exists — ignore
- }
-
- // ─── Append-only chunk log (replaces the old `messages` blob table) ──
- //
- // A conversation is stored as a flat, append-only stream of chunk rows
- // keyed by a per-tab monotonic `seq`. "Message" and "turn" are DERIVED
- // groupings (see db/chunks.ts), never stored containers. This is what
- // powers per-chunk frontend pagination AND the stable per-step wire
- // format that fixes Anthropic prompt-cache churn (see notes/plan-chunk-log.md).
- //
- // role : 'user' | 'assistant' | 'tool' | 'system'
- // type : 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'error' | 'system'
- // step : LLM round-trip index within a turn (user/system rows = 0)
- // data_json: the type-specific payload (see ChunkData in types)
- _db.run(`CREATE TABLE IF NOT EXISTS chunks (
- id TEXT PRIMARY KEY,
- tab_id TEXT NOT NULL,
- seq INTEGER NOT NULL,
- turn_id TEXT NOT NULL,
- step INTEGER NOT NULL DEFAULT 0,
- role TEXT NOT NULL,
- type TEXT NOT NULL,
- data_json TEXT NOT NULL,
- created_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE INDEX IF NOT EXISTS idx_chunks_tab_seq ON chunks(tab_id, seq)`);
-
- // One-shot migration off the legacy `messages` blob model. Beta software,
- // no backward compatibility: the old chat history is destroyed (tabs +
- // messages), while settings / credentials / api_keys / usage_cache /
- // wake_schedule are preserved. Detect the old schema by the presence of
- // the `messages` table; once dropped, this branch never runs again.
- const hasLegacyMessages = _db
- .query("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'")
- .get() as { name: string } | null;
- if (hasLegacyMessages) {
- _db.run("DROP TABLE IF EXISTS messages");
- // Clear conversation containers too (fresh slate for the new model).
- _db.run("DELETE FROM tabs");
- _db.run("DELETE FROM chunks");
- }
-
- _db.run(`CREATE TABLE IF NOT EXISTS settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- )`);
-
- return _db;
-}
-
-/** Close the database connection (e.g. on shutdown). */
-export function closeDatabase(): void {
- if (_db) {
- _db.close();
- _db = null;
- }
-}
-
-/** Returns the path where the database file lives (or will live). */
-export function getDatabasePath(): string {
- if (_db) return _db.filename;
- const dir = getDataDir();
- return join(dir, "dispatch.db");
-}
diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts
deleted file mode 100644
index f9d152e..0000000
--- a/packages/core/src/db/settings.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export function getSetting(key: string): string | null {
- const db = getDatabase();
- const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as {
- value: string;
- } | null;
- return row?.value ?? null;
-}
-
-export function setSetting(key: string, value: string): void {
- const db = getDatabase();
- db.query(
- `INSERT INTO settings (key, value) VALUES ($key, $value)
- ON CONFLICT(key) DO UPDATE SET value = $value`,
- ).run({ $key: key, $value: value });
-}
-
-export function deleteSetting(key: string): void {
- const db = getDatabase();
- db.query("DELETE FROM settings WHERE key = $key").run({ $key: key });
-}
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
deleted file mode 100644
index f719a01..0000000
--- a/packages/core/src/db/tabs.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export interface TabRow {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-
-function rowToTab(row: Record<string, unknown>): TabRow {
- return {
- id: row.id as string,
- title: row.title as string,
- keyId: row.key_id as string | null,
- modelId: row.model_id as string | null,
- parentTabId: (row.parent_tab_id as string) ?? null,
- status: row.status as string,
- isOpen: (row.is_open as number) === 1,
- position: row.position as number,
- createdAt: row.created_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-export function createTab(
- id: string,
- title: string,
- options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null },
-): TabRow {
- const db = getDatabase();
- const now = Date.now();
- const maxPos = db
- .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1")
- .get() as { max_pos: number };
- const position = (maxPos?.max_pos ?? -1) + 1;
- const keyId = options?.keyId ?? null;
- const modelId = options?.modelId ?? null;
- const parentTabId = options?.parentTabId ?? null;
- db.query(
- `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`,
- ).run({
- $id: id,
- $title: title,
- $keyId: keyId,
- $modelId: modelId,
- $parentTabId: parentTabId,
- $position: position,
- $now: now,
- });
- return {
- id,
- title,
- keyId,
- modelId,
- parentTabId,
- status: "idle",
- isOpen: true,
- position,
- createdAt: now,
- updatedAt: now,
- };
-}
-
-export function getTab(id: string): TabRow | null {
- const db = getDatabase();
- const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record<
- string,
- unknown
- > | null;
- return row ? rowToTab(row) : null;
-}
-
-export function listOpenTabs(): TabRow[] {
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC")
- .all() as Array<Record<string, unknown>>;
- return rows.map(rowToTab);
-}
-
-export function updateTabTitle(id: string, title: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({
- $id: id,
- $title: title,
- $now: Date.now(),
- });
-}
-
-export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void {
- const db = getDatabase();
- db.query(
- "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id",
- ).run({
- $id: id,
- $keyId: keyId,
- $modelId: modelId,
- $now: Date.now(),
- });
-}
-
-export function updateTabStatus(id: string, status: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({
- $id: id,
- $status: status,
- $now: Date.now(),
- });
-}
-
-export function updateTabPositions(idsInOrder: string[]): void {
- const db = getDatabase();
- const now = Date.now();
- const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id");
- // One transaction so a reorder is atomic: either every tab lands at its new
- // slot or none does, never a half-applied ordering.
- const applyAll = db.transaction(() => {
- idsInOrder.forEach((id, index) => {
- update.run({ $id: id, $position: index, $now: now });
- });
- });
- applyAll();
-}
-
-export function archiveTab(id: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({
- $id: id,
- $now: Date.now(),
- });
-}
-
-/**
- * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first
- * order (children before their parent). Archived descendants
- * (`is_open = 0`) and their sub-trees are skipped — closing a parent
- * shouldn't drag archived branches back into view.
- *
- * The starting `rootId` is always included in the result, even if no row
- * with that id exists in the `tabs` table (graceful handling for stale
- * references).
- *
- * Order matters for the cascade-close path: callers archive descendants
- * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on
- * partially-deleted parents.
- *
- * Cycle-safe: a `visited` set guards against accidental `parent_tab_id`
- * loops that would otherwise spin forever.
- */
-export function getDescendantIds(rootId: string): string[] {
- const db = getDatabase();
- const visited = new Set<string>();
- const order: string[] = [];
- const queue: string[] = [rootId];
- while (queue.length > 0) {
- const id = queue.shift() as string;
- if (visited.has(id)) continue;
- visited.add(id);
- order.push(id);
- const children = db
- .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1")
- .all({ $id: id }) as Array<{ id: string }>;
- for (const child of children) {
- if (!visited.has(child.id)) queue.push(child.id);
- }
- }
- return order.reverse();
-}
-
-/**
- * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`.
- * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything
- * shorter is rejected as too broad — an agent must echo at least the 4-char
- * handle shown in the UI.
- */
-export const MIN_TAB_PREFIX_LENGTH = 4;
-
-/**
- * Outcome of resolving a short tab handle (a git-style prefix of a tab's
- * UUID) back to a concrete open tab.
- *
- * - `ok` — exactly one open tab matched; `tab` is it.
- * - `none` — no open tab matched (bad/stale handle, or too-short prefix).
- * - `ambiguous` — more than one open tab shares the prefix; `matches` lists
- * them so the caller can ask for one more character (the same
- * UX as `git checkout <ambiguous-sha>`).
- */
-export type ResolveTabPrefixResult =
- | { status: "ok"; tab: TabRow }
- | { status: "none" }
- | { status: "ambiguous"; matches: TabRow[] };
-
-/**
- * Resolve a short tab handle to a single OPEN tab by prefix match — the
- * git-short-hash model. The handle is NEVER stored: it is always derived from
- * (and matched against) the canonical lowercase UUID in `tabs.id`.
- *
- * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and
- * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We
- * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also
- * ASCII-case-insensitive by default) and strip everything outside the UUID
- * alphabet `[0-9a-f-]` so no wildcard can survive into the query.
- *
- * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns
- * `none` rather than matching a large swath of tabs.
- *
- * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix
- * must not cause phantom ambiguity or resolve to a dead conversation.
- */
-export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult {
- const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
- if (sanitized.length < MIN_TAB_PREFIX_LENGTH) {
- return { status: "none" };
- }
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC")
- .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>;
- if (rows.length === 0) return { status: "none" };
- if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) };
- return { status: "ambiguous", matches: rows.map(rowToTab) };
-}
-
-/**
- * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars)
- * that identifies `tabId` among the currently OPEN tabs — the backend twin of
- * the frontend's display helper. Used when a tool needs to echo a tab's own
- * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a
- * value from the wire.
- *
- * Returns the full id if no shorter unique prefix exists (degenerate — only if
- * two open tabs share an entire id, which UUID uniqueness precludes).
- */
-export function shortestUniquePrefix(tabId: string): string {
- const db = getDatabase();
- const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>;
- const others = rows.map((r) => r.id).filter((id) => id !== tabId);
- for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) {
- const candidate = tabId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return tabId;
-}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
deleted file mode 100644
index e951d08..0000000
--- a/packages/core/src/index.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-// @dispatch/core — Agent runtime, LLM integration, tools
-
-// Agent & LLM
-export { Agent } from "./agent/agent.js";
-export {
- deleteAgent,
- expandAgentToolNames,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getAgentDirs,
- getProjectAgentsDir,
- loadAgent,
- loadAgents,
- saveAgent,
-} from "./agents/index.js";
-// Chunk helpers
-export {
- appendEventToChunks,
- applySystemEvent,
- type IdentifiedMessage,
- type SystemEventLike,
-} from "./chunks/append.js";
-// Compaction
-export {
- buildCompactionPrompt,
- buildCompactionRequest,
- buildSummaryTurnText,
- type CompactionRequest,
- DEFAULT_TAIL_TURNS,
- extractPreviousSummary,
- type HeadTailSelection,
- renderTranscript,
- SUMMARY_MARKER,
- SUMMARY_TEMPLATE,
- selectHeadTail,
- TOOL_OUTPUT_MAX_CHARS,
-} from "./compaction/index.js";
-// Config
-export {
- configToRuleset,
- createConfigWatcher,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
- validateConfig,
- watchDirConfig,
-} from "./config/index.js";
-// Credentials
-export * from "./credentials/index.js";
-export {
- appendChunks,
- clearChunksForTab,
- explodeTurn,
- explodeUserText,
- getChunksForTab,
- getMessagesForTab,
- getTotalChunkCount,
- getUsageStatsForTab,
- groupRowsToMessages,
- type MessageRow,
- rekeyChunks,
-} from "./db/chunks.js";
-// Database
-export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js";
-export { deleteSetting, getSetting, setSetting } from "./db/settings.js";
-// Tabs & Messages
-export {
- archiveTab,
- createTab,
- getTab,
- listOpenTabs,
- MIN_TAB_PREFIX_LENGTH,
- type ResolveTabPrefixResult,
- resolveTabPrefix,
- shortestUniquePrefix,
- type TabRow,
- updateTabModel,
- updateTabPositions,
- updateTabStatus,
- updateTabTitle,
-} from "./db/tabs.js";
-export {
- debugVerbosity,
- isDebugEnabled,
- logAgentLoop,
- logStepLifecycle,
- logStreamEvent,
-} from "./llm/debug-logger.js";
-export { createProvider } from "./llm/provider.js";
-// LSP (Language Server Protocol)
-export {
- createLspClient,
- type Diagnostic as LspDiagnostic,
- type LspClient,
- LspManager,
- type LspServerHandle,
- pretty as prettyDiagnostic,
- type ResolvedLspServer,
- report as reportDiagnostics,
- resolveServersFromConfig,
-} from "./lsp/index.js";
-// Models
-export {
- ACCEPTED_ATTACHMENT_MEDIA_TYPES,
- ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
- type AttachmentValidationError,
- type AttachmentValidationResult,
- base64ByteLength,
- getModelsCatalog,
- hasAttachments,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- type ModelInputCapabilities,
- ModelRegistry,
- resolveContextLimit,
- resolveModelCapabilities,
- validateUserContent,
-} from "./models/index.js";
-// Notifications (ntfy.sh)
-export * from "./notifications/index.js";
-export * from "./permission/index.js";
-// Skills
-export {
- createSkillsWatcher,
- getSkillByName,
- loadSkills,
- parseSkillFile,
- resolveSkillsForAgent,
-} from "./skills/index.js";
-export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
-// Tools
-export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js";
-export { createListFilesTool } from "./tools/list-files.js";
-export { createLspTool, type LspToolContext } from "./tools/lsp.js";
-export { createReadFileTool } from "./tools/read-file.js";
-export { createReadFileSliceTool } from "./tools/read-file-slice.js";
-export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js";
-export { createToolRegistry } from "./tools/registry.js";
-export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
-export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
-export { createSearchCodeTool } from "./tools/search-code.js";
-export {
- createSendToTabTool,
- type ResolvedTabRef,
- type SendToTabCallbacks,
- type TabResolution,
-} from "./tools/send-to-tab.js";
-export { analyzeCommand } from "./tools/shell-analyze.js";
-export {
- type AvailableAgent,
- createSummonTool,
- type SummonCallbacks,
- toAvailableSubagents,
- toAvailableUserAgents,
-} from "./tools/summon.js";
-export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js";
-export { clearSpillForTab } from "./tools/truncate.js";
-export { createWebSearchTool } from "./tools/web-search.js";
-export { type AfterWriteHook, createWriteFileTool } from "./tools/write-file.js";
-export {
- BackgroundTranscriptStore,
- createYoutubeTranscribeTool,
-} from "./tools/youtube-transcribe.js";
-// Types & Permissions
-export * from "./types/index.js";
diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts
deleted file mode 100644
index 467a307..0000000
--- a/packages/core/src/llm/anthropic-oauth-transform.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow.
- *
- * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code-
- * billed requests. A genuine Claude Code request looks like:
- *
- * system: [
- * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control
- * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.",
- * cache_control: { type: "ephemeral" } }, // identity, separate block
- * ]
- * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ]
- *
- * i.e. ONLY the billing header and the verbatim identity string may live in
- * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions)
- * MUST be relocated into the first user message. When third-party content stays
- * in `system[]` next to the identity, Anthropic bills it as premium "extra
- * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope —
- * producing the 0% cache hit rate and ballooning cost we were seeing.
- *
- * Dispatch builds its system prompt as ONE concatenated block
- * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes
- * that into a single `system[]` text entry. This transform runs at fetch time
- * on the already-serialized JSON body and reshapes it into the structure above.
- *
- * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`),
- * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the
- * agent, so this transform leaves tools and messages (other than the relocation)
- * untouched. It is defensive: any parse/shape surprise returns the body
- * unchanged so a transform bug can never break a request.
- */
-
-const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-const BILLING_PREFIX = "x-anthropic-billing-header";
-
-type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record<
- string,
- unknown
->;
-
-interface AnthropicRequestBody {
- system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
- messages?: Array<{
- role?: string;
- content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
- }>;
- [key: string]: unknown;
-}
-
-/**
- * Restructure a serialized Anthropic request body string for the Claude Code
- * OAuth flow. Returns the (possibly rewritten) body, or the original input
- * unchanged when it isn't a JSON string we recognize.
- */
-export function transformClaudeOAuthBody(
- body: BodyInit | null | undefined,
-): BodyInit | null | undefined {
- if (typeof body !== "string") return body;
- let parsed: AnthropicRequestBody;
- try {
- parsed = JSON.parse(body) as AnthropicRequestBody;
- } catch {
- return body;
- }
- if (!parsed || typeof parsed !== "object") return body;
- try {
- const changed = restructureSystem(parsed);
- return changed ? JSON.stringify(parsed) : body;
- } catch {
- // Never let a transform bug break a real request.
- return body;
- }
-}
-
-/**
- * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if
- * anything changed (so the caller knows to re-serialize).
- */
-function restructureSystem(parsed: AnthropicRequestBody): boolean {
- const raw = parsed.system;
- let entries: SystemBlock[];
- if (typeof raw === "string") {
- entries = [{ type: "text", text: raw }];
- } else if (Array.isArray(raw)) {
- entries = raw.map((e) =>
- typeof e === "string"
- ? { type: "text", text: e }
- : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock),
- );
- } else {
- return false; // no system field — nothing to do
- }
-
- const combined = entries.map((e) => e.text).join("\n\n");
-
- // Only act on Claude Code shaped requests (must carry the identity string).
- if (!combined.includes(SYSTEM_IDENTITY)) return false;
-
- const hadCacheControl = entries.some((e) => e.cache_control != null);
-
- // Peel the billing-header line out (it is a single line with no newlines).
- const lines = combined.split("\n");
- const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX));
- let billingLine: string | null = null;
- if (billingIdx !== -1) {
- billingLine = lines[billingIdx] ?? null;
- lines.splice(billingIdx, 1);
- }
- const afterBilling = lines.join("\n").replace(/^\n+/, "");
-
- // Split the identity prefix from the rest (Dispatch's real system prompt).
- let rest = "";
- if (afterBilling.startsWith(SYSTEM_IDENTITY)) {
- rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, "");
- } else {
- // Identity is present but not at the front (unexpected) — still isolate it.
- rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, "");
- }
-
- // Rebuild system[]: billing (no cache_control) then identity (cached).
- const newSystem: SystemBlock[] = [];
- if (billingLine) newSystem.push({ type: "text", text: billingLine });
- const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY };
- if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" };
- newSystem.push(identityBlock);
-
- // Relocate the third-party system prompt into the first user message.
- if (rest.length > 0) {
- const firstUser = Array.isArray(parsed.messages)
- ? parsed.messages.find((m) => m.role === "user")
- : undefined;
- if (firstUser) {
- if (typeof firstUser.content === "string") {
- firstUser.content = `${rest}\n\n${firstUser.content}`;
- } else if (Array.isArray(firstUser.content)) {
- firstUser.content.unshift({ type: "text", text: rest });
- } else {
- firstUser.content = rest;
- }
- } else {
- // No user message to host it — keep it as a (cached) system block so
- // the request still carries the instructions.
- const restBlock: SystemBlock = { type: "text", text: rest };
- if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" };
- newSystem.push(restBlock);
- }
- }
-
- parsed.system = newSystem;
- return true;
-}
-
-export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX };
diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts
deleted file mode 100644
index 072a7a1..0000000
--- a/packages/core/src/llm/debug-logger.ts
+++ /dev/null
@@ -1,448 +0,0 @@
-/**
- * Debug logger for LLM API requests and responses.
- *
- * Enable via environment variable: DISPATCH_DEBUG_LLM=1
- *
- * Logs every outgoing request body and incoming response body to timestamped
- * files under `DISPATCH_DEBUG_LLM_DIR` (default: /tmp/dispatch/llm-debug/).
- *
- * Each request/response pair shares a sequence number for easy correlation.
- * Files are named: `{seq}_{timestamp}_{direction}_{model}.json`
- *
- * For streaming responses (SSE), the raw chunks are captured as they arrive
- * and written out as a JSON array when the stream completes.
- *
- * Additional logging layers:
- * - Stream events: every AI SDK stream event (text-delta, tool-call, etc.)
- * - Step lifecycle: step start/end, tool execution timing
- * - Agent loop: step count, break conditions, tool call counts
- *
- * All output goes to stderr (console.error) for stream event logs, and to
- * files for request/response bodies (too large for console).
- */
-
-import { mkdirSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-
-const ENABLED = !!process.env.DISPATCH_DEBUG_LLM;
-const LOG_DIR = process.env.DISPATCH_DEBUG_LLM_DIR || "/tmp/dispatch/llm-debug";
-let seq = 0;
-
-/** Verbosity levels:
- * 1 = requests/responses only (files)
- * 2 = + stream events to stderr
- * 3 = + step lifecycle + agent loop details to stderr
- */
-const VERBOSITY = Math.max(1, Number(process.env.DISPATCH_DEBUG_LLM_VERBOSITY) || 1);
-
-function ensureDir(): void {
- try {
- mkdirSync(LOG_DIR, { recursive: true });
- } catch {
- // best effort
- }
-}
-
-function ts(): string {
- return new Date().toISOString().replace(/[:.]/g, "-");
-}
-
-function sanitizeModel(model: string): string {
- return model.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 60);
-}
-
-function sanitizeTab(tabId?: string): string {
- if (!tabId) return "notab";
- return tabId.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 40);
-}
-
-export function isDebugEnabled(): boolean {
- return ENABLED;
-}
-
-export function debugVerbosity(): number {
- return ENABLED ? VERBOSITY : 0;
-}
-
-/**
- * Allocate a fresh sequence number. Used by the fetch wrapper so the request
- * and the response share the same id without needing a separate `logRequest`
- * call from the agent loop (which doesn't see the actual HTTP body anyway).
- */
-export function nextDebugSeq(): number {
- return ++seq;
-}
-
-/**
- * Log an outgoing request to the AI model endpoint.
- * Returns a request ID for correlating with the response.
- */
-export function logRequest(data: {
- model: string;
- url?: string;
- method?: string;
- headers?: Record<string, string>;
- body: unknown;
- tabId?: string;
- step?: number;
- provider?: string;
-}): number {
- if (!ENABLED) return -1;
- ensureDir();
- const id = ++seq;
- const filename = `${String(id).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_REQ_${sanitizeModel(data.model)}.json`;
- const payload = {
- _debug: {
- seq: id,
- direction: "request",
- timestamp: new Date().toISOString(),
- tabId: data.tabId,
- step: data.step,
- provider: data.provider,
- },
- url: data.url,
- method: data.method ?? "POST",
- headers: data.headers,
- body: data.body,
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write request log: ${err}`);
- }
- console.error(
- `[dispatch-debug] REQ #${id} → ${data.model} (step=${data.step ?? "?"}, tab=${data.tabId ?? "?"})`,
- );
- return id;
-}
-
-/**
- * Log the raw fetch-level request (the actual HTTP body sent to the provider).
- * Called from the instrumented fetch wrapper.
- */
-export function logRawFetchRequest(data: {
- requestId: number;
- url: string;
- method: string;
- headers: Record<string, string>;
- body: string | null;
- tabId?: string;
-}): void {
- if (!ENABLED) return;
- ensureDir();
- const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_REQ.json`;
- const payload = {
- _debug: {
- seq: data.requestId,
- direction: "raw-request",
- timestamp: new Date().toISOString(),
- tabId: data.tabId,
- },
- url: data.url,
- method: data.method,
- headers: data.headers,
- body: tryParseJson(data.body),
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write raw request log: ${err}`);
- }
-}
-
-/**
- * Log the raw fetch-level response (HTTP status, headers, body).
- */
-export function logRawFetchResponse(data: {
- requestId: number;
- url: string;
- status: number;
- statusText: string;
- headers: Record<string, string>;
- body: string | null;
- isStreaming: boolean;
- tabId?: string;
-}): void {
- if (!ENABLED) return;
- ensureDir();
- const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_RES_${data.status}.json`;
- const payload = {
- _debug: {
- seq: data.requestId,
- direction: "raw-response",
- timestamp: new Date().toISOString(),
- isStreaming: data.isStreaming,
- tabId: data.tabId,
- },
- url: data.url,
- status: data.status,
- statusText: data.statusText,
- headers: data.headers,
- body: tryParseJson(data.body),
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write raw response log: ${err}`);
- }
-}
-
-/**
- * Accumulator for streaming response chunks. Call `addChunk()` as SSE events
- * arrive, then `flush()` when the stream ends to write them all to disk.
- */
-export class StreamResponseLogger {
- private requestId: number;
- private model: string;
- private tabId?: string;
- private chunks: Array<{ timestamp: string; data: string }> = [];
- private startTime: number;
-
- constructor(requestId: number, model: string, tabId?: string) {
- this.requestId = requestId;
- this.model = model;
- this.tabId = tabId;
- this.startTime = Date.now();
- }
-
- addChunk(rawLine: string): void {
- if (!ENABLED) return;
- this.chunks.push({
- timestamp: new Date().toISOString(),
- data: rawLine,
- });
- }
-
- flush(meta?: { finishReason?: string; error?: string }): void {
- if (!ENABLED) return;
- ensureDir();
- const elapsed = Date.now() - this.startTime;
- const filename = `${String(this.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(this.tabId)}_STREAM_RES_${sanitizeModel(this.model)}.json`;
- const payload = {
- _debug: {
- seq: this.requestId,
- direction: "stream-response",
- timestamp: new Date().toISOString(),
- tabId: this.tabId,
- model: this.model,
- elapsedMs: elapsed,
- chunkCount: this.chunks.length,
- ...meta,
- },
- chunks: this.chunks,
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write stream response log: ${err}`);
- }
- console.error(
- `[dispatch-debug] STREAM #${this.requestId} complete: ${this.chunks.length} chunks in ${elapsed}ms (${this.model})`,
- );
- }
-}
-
-/**
- * Log an AI SDK stream event (text-delta, tool-call, finish-step, etc.).
- * Only logs at verbosity >= 2.
- */
-export function logStreamEvent(data: {
- requestId: number;
- step: number;
- eventType: string;
- detail?: unknown;
- tabId?: string;
-}): void {
- if (!ENABLED || VERBOSITY < 2) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(
- `[dispatch-debug] STREAM_EVENT #${data.requestId} step=${data.step} ${data.eventType}${detail}`,
- );
-}
-
-/**
- * Log step lifecycle events (step start, tool execution, step end).
- * Only logs at verbosity >= 3.
- */
-export function logStepLifecycle(data: {
- tabId?: string;
- step: number;
- event: string;
- detail?: unknown;
-}): void {
- if (!ENABLED || VERBOSITY < 3) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(
- `[dispatch-debug] STEP tab=${data.tabId ?? "?"} step=${data.step} ${data.event}${detail}`,
- );
-}
-
-/**
- * Log agent loop-level events (loop start, break conditions, etc.).
- * Only logs at verbosity >= 3.
- */
-export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void {
- if (!ENABLED || VERBOSITY < 3) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`);
-}
-
-/**
- * Wrap a fetch function so every request/response pair is logged to disk
- * under `DISPATCH_DEBUG_LLM_DIR` when `DISPATCH_DEBUG_LLM` is set. When
- * disabled, returns the input fetch unchanged (zero overhead).
- *
- * Critical implementation note — SSE bodies: the AI SDK consumes
- * `response.body` as a `ReadableStream`. Reading it from anywhere else
- * (e.g. calling `.text()`) drains the stream and the SDK gets an empty
- * body. We therefore `response.clone()` the response and tee its body via
- * a `TransformStream` so each SSE line is forwarded to the SDK AND
- * captured into a `StreamResponseLogger`. The clone returns its own
- * Response object whose body the SDK reads normally.
- *
- * For non-streaming responses (`content-type` is not `text/event-stream`)
- * we just clone and read once via `.text()` — simpler and safe because
- * non-streaming bodies are bounded.
- */
-export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>(
- baseFetch: F,
- opts: { tabId?: string; modelHint?: string },
-): F {
- if (!ENABLED) return baseFetch;
- const wrapped = async (...args: Parameters<F>) => {
- const requestId = ++seq;
- const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined];
- const url =
- typeof input === "string"
- ? input
- : input instanceof URL
- ? input.toString()
- : (input as Request).url;
- const method =
- init?.method ??
- (typeof input === "object" && "method" in input ? (input as Request).method : "POST");
-
- // Snapshot headers as a plain object for logging.
- const headerObj: Record<string, string> = {};
- try {
- const h = new Headers(init?.headers);
- h.forEach((v, k) => {
- // Redact bearer / api-key headers — useful in shared logs.
- if (/^(authorization|x-api-key|cookie)$/i.test(k)) {
- headerObj[k] = "<redacted>";
- } else {
- headerObj[k] = v;
- }
- });
- } catch {
- // best effort
- }
-
- // Capture request body. Most providers send a JSON string here; if it's
- // a stream/blob/etc. we skip body logging (rare in our codebase).
- let bodyStr: string | null = null;
- if (typeof init?.body === "string") {
- bodyStr = init.body;
- } else if (init?.body instanceof Uint8Array) {
- bodyStr = new TextDecoder().decode(init.body);
- }
-
- logRawFetchRequest({
- requestId,
- url,
- method,
- headers: headerObj,
- body: bodyStr,
- tabId: opts.tabId,
- });
-
- const response = await (baseFetch as unknown as typeof fetch)(input, init);
-
- const respHeaders: Record<string, string> = {};
- response.headers.forEach((v, k) => {
- respHeaders[k] = v;
- });
- const contentType = response.headers.get("content-type") ?? "";
- const isStreaming = contentType.includes("text/event-stream");
-
- if (!isStreaming) {
- // Clone so we don't drain the SDK's copy. Bounded body — safe to read.
- try {
- const cloned = response.clone();
- const text = await cloned.text();
- logRawFetchResponse({
- requestId,
- url,
- status: response.status,
- statusText: response.statusText,
- headers: respHeaders,
- body: text,
- isStreaming: false,
- tabId: opts.tabId,
- });
- } catch (err) {
- console.error(`[dispatch-debug] Failed to clone non-stream response: ${err}`);
- }
- return response;
- }
-
- // Streaming path: write a header file with status + headers immediately
- // (the body file comes later via StreamResponseLogger.flush).
- logRawFetchResponse({
- requestId,
- url,
- status: response.status,
- statusText: response.statusText,
- headers: respHeaders,
- body: null,
- isStreaming: true,
- tabId: opts.tabId,
- });
-
- // Tee the body through a TransformStream so each SSE chunk is captured
- // without consuming the stream the SDK needs.
- const streamLogger = new StreamResponseLogger(
- requestId,
- opts.modelHint ?? "stream",
- opts.tabId,
- );
- const decoder = new TextDecoder();
- const tee = new TransformStream<Uint8Array, Uint8Array>({
- transform(chunk, controller) {
- try {
- streamLogger.addChunk(decoder.decode(chunk, { stream: true }));
- } catch {
- // never let logging break the stream
- }
- controller.enqueue(chunk);
- },
- flush() {
- try {
- streamLogger.flush();
- } catch {
- // best effort
- }
- },
- });
-
- // `response.body` is `ReadableStream<Uint8Array> | null`. If null (no
- // body), there's nothing to tee — return as-is.
- if (!response.body) return response;
- const teed = response.body.pipeThrough(tee);
- return new Response(teed, {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- });
- };
- return wrapped as unknown as F;
-}
-
-function tryParseJson(s: string | null): unknown {
- if (s === null) return null;
- try {
- return JSON.parse(s);
- } catch {
- return s;
- }
-}
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
deleted file mode 100644
index ca734f9..0000000
--- a/packages/core/src/llm/provider.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { createAnthropic } from "@ai-sdk/anthropic";
-import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
-import type { LanguageModelV3 } from "@ai-sdk/provider";
-import type { FetchFunction } from "@ai-sdk/provider-utils";
-import { getAnthropicBetas } from "../credentials/anthropic-betas.js";
-import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js";
-import { wrapFetchWithLogging } from "./debug-logger.js";
-
-export interface ProviderConfig {
- apiKey: string;
- baseURL: string;
- provider?: string;
- claudeCredentials?: {
- accessToken: string;
- };
- /** Optional tab id for labelling debug logs. No effect when
- * `DISPATCH_DEBUG_LLM` is unset. */
- tabId?: string;
-}
-
-const MCP_PREFIX = "mcp_";
-
-function prefixToolName(name: string): string {
- return `${MCP_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
-}
-
-function unprefixToolName(name: string): string {
- if (name.startsWith(MCP_PREFIX)) {
- const rest = name.slice(MCP_PREFIX.length);
- return `${rest.charAt(0).toLowerCase()}${rest.slice(1)}`;
- }
- return name;
-}
-
-// Explicit factory return type so the inferred type doesn't leak references
-// into transitive `@ai-sdk/provider` paths (which would trip TS2742).
-// `@ai-sdk/anthropic` v3.x and `@ai-sdk/openai-compatible` v2.x both return
-// `LanguageModelV3`-spec models; `wrapLanguageModel` likewise.
-export type ModelFactory = (modelId: string) => LanguageModelV3;
-
-export function createProvider(config: ProviderConfig): ModelFactory {
- if (config.provider === "anthropic") {
- return createClaudeOAuthProvider(config);
- }
-
- if (config.provider === "opencode-anthropic") {
- return createApiKeyAnthropicProvider(config);
- }
-
- // Default: OpenAI-compatible provider (OpenCode Zen — DeepSeek, GLM,
- // Kimi, MiniMax, etc.).
- //
- // `@ai-sdk/[email protected]` handles reasoning round-tripping
- // natively: it reads `{ type: "reasoning", text }` parts from each
- // assistant message's content and emits them as `reasoning_content`
- // on the wire (see node_modules/@ai-sdk/openai-compatible/dist/index.mjs
- // lines 215-216 and 245). Our `toModelMessages` in agent.ts already
- // emits reasoning parts from `ThinkingChunk`s, so no middleware is
- // needed.
- //
- // (The v4-era `normalizeMessages` middleware that lived here was
- // actively breaking DeepSeek: it stripped reasoning parts from
- // content AND wrote them under `providerMetadata` — wrong key in v3
- // prompts, which use `providerOptions`. The result was that
- // reasoning_content never reached the wire and DeepSeek rejected the
- // follow-up turn with "must be passed back".)
- //
- // Debug logging: when DISPATCH_DEBUG_LLM is set, wrap the base fetch
- // so every wire request/response (including SSE chunks) is captured.
- // When disabled, `wrapFetchWithLogging` returns the input unchanged
- // (zero overhead).
- const loggingFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "opencode-zen",
- }) as unknown as FetchFunction;
-
- const provider = createOpenAICompatible({
- name: "opencode-zen",
- apiKey: config.apiKey,
- baseURL: config.baseURL,
- fetch: loggingFetch,
- });
-
- return (modelId: string) => provider(modelId);
-}
-
-/**
- * Claude OAuth provider. Used by Dispatch's `anthropic` provider keys
- * (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer`
- * (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI
- * request headers so the request bills against the user's Claude subscription.
- *
- * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits
- * an `anthropic-beta` header for betas it auto-derives from tool definitions
- * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching
- * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API
- * silently ignores every `cache_control` breakpoint we attach to messages,
- * giving a 0% cache hit rate and a massive token burn (see notes/claude-report.md).
- * The SDK folds any `anthropic-beta` it finds on the provider's config headers
- * back into its own beta set (via `getBetasFromHeaders`), so the values here
- * are merged — not overwritten — with any tool-derived betas.
- */
-function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
- // Stable per-provider session id — mirrors the Claude Code CLI, which sends
- // the same `X-Claude-Code-Session-Id` across a session's requests.
- const sessionId = randomUUID();
-
- // Wrap the base fetch FIRST so the logging wrapper sees the genuine
- // outgoing HTTP body — i.e. AFTER the OAuth body transform and AFTER the
- // Claude-Code session headers have been stamped on. Order matters: if we
- // wrapped the inner `baseFetch` instead, the logs would show the pre-
- // transform body and miss the session headers, defeating the point of
- // capturing the wire for cache/billing debugging.
- const baseFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "claude-oauth",
- });
-
- // Custom fetch that (1) restructures the request body into the genuine
- // Claude Code system layout — required for Anthropic to bill correctly and
- // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and
- // (2) stamps the Claude Code session/request id headers the real CLI sends.
- // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose
- // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy.
- const oauthFetch = (async (
- input: Parameters<FetchFunction>[0],
- init?: Parameters<FetchFunction>[1],
- ) => {
- const nextInit: RequestInit = { ...init };
- if (init?.body != null) {
- nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body;
- }
- const headers = new Headers(init?.headers);
- headers.set("X-Claude-Code-Session-Id", sessionId);
- if (!headers.has("x-client-request-id")) {
- headers.set("x-client-request-id", randomUUID());
- }
- nextInit.headers = headers;
- return baseFetch(input, nextInit);
- }) as unknown as FetchFunction;
-
- const anthropic = createAnthropic({
- baseURL: config.baseURL || "https://api.anthropic.com/v1",
- authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
- fetch: oauthFetch,
- headers: {
- "anthropic-beta": getAnthropicBetas().join(","),
- "anthropic-dangerous-direct-browser-access": "true",
- "x-app": "cli",
- "user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
- },
- });
- return (modelId: string) => anthropic(modelId);
-}
-
-/**
- * Plain-API-key Anthropic-format provider. Used to hit gateways that speak
- * Anthropic's `/messages` protocol with a standard `x-api-key` header — most
- * importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth
- * variant, no `claudeCredentials` are present, no Claude Code mimicry headers
- * are sent, and the API key is passed verbatim through the SDK's default
- * authentication path.
- */
-function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory {
- const loggingFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "opencode-anthropic",
- }) as unknown as FetchFunction;
-
- const anthropic = createAnthropic({
- apiKey: config.apiKey,
- baseURL: config.baseURL || "https://opencode.ai/zen/go/v1",
- fetch: loggingFetch,
- });
-
- return (modelId: string) => anthropic(modelId);
-}
-
-export { prefixToolName, unprefixToolName };
diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts
deleted file mode 100644
index da0c916..0000000
--- a/packages/core/src/lsp/client.ts
+++ /dev/null
@@ -1,658 +0,0 @@
-import type { ChildProcessWithoutNullStreams } from "node:child_process";
-import { readFile } from "node:fs/promises";
-import { extname, isAbsolute, resolve } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import {
- createMessageConnection,
- type MessageConnection,
- StreamMessageReader,
- StreamMessageWriter,
-} from "vscode-jsonrpc/node";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { languageIdForExtension } from "./language.js";
-
-export type { Diagnostic } from "vscode-languageserver-types";
-
-// ─── Timing constants (mirrors opencode) ─────────────────────────
-const DIAGNOSTICS_DEBOUNCE_MS = 150;
-const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000;
-const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000;
-const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000;
-const INITIALIZE_TIMEOUT_MS = 45_000;
-
-// ─── LSP spec constants ──────────────────────────────────────────
-const FILE_CHANGE_CREATED = 1;
-const FILE_CHANGE_CHANGED = 2;
-const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2;
-
-/**
- * A live spawned language-server process plus the `initializationOptions` to
- * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by
- * `createLspClient`.
- */
-export interface LspServerHandle {
- process: ChildProcessWithoutNullStreams;
- initialization?: Record<string, unknown>;
-}
-
-interface ServerCapabilities {
- textDocumentSync?: number | { change?: number };
- diagnosticProvider?: unknown;
- [key: string]: unknown;
-}
-
-interface DiagnosticRequestResult {
- handled: boolean;
- matched: boolean;
- byFile: Map<string, Diagnostic[]>;
-}
-
-interface CapabilityRegistration {
- id: string;
- method: string;
- registerOptions?: {
- identifier?: string;
- workspaceDiagnostics?: boolean;
- };
-}
-
-type DocumentDiagnosticReport = {
- items?: Diagnostic[];
- relatedDocuments?: Record<string, DocumentDiagnosticReport>;
-};
-
-type WorkspaceDiagnosticReport = {
- items?: { uri?: string; items?: Diagnostic[] }[];
-};
-
-/** Public shape of a connected LSP client. */
-export interface LspClient {
- readonly serverID: string;
- readonly root: string;
- readonly connection: MessageConnection;
- /**
- * Open (or re-sync) a file with the server. Returns the document version
- * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching
- * this exact sync.
- */
- notifyOpen(path: string): Promise<number>;
- /** Snapshot of all known diagnostics keyed by absolute file path. */
- readonly diagnostics: Map<string, Diagnostic[]>;
- /** Wait until diagnostics for `path` settle (push and/or pull). */
- waitForDiagnostics(request: {
- path: string;
- version: number;
- mode?: "document" | "full";
- after?: number;
- }): Promise<void>;
- /** Generic LSP request passthrough (hover, definition, references, …). */
- request<T = unknown>(method: string, params: unknown): Promise<T | null>;
- /** Shut the connection and child process down. */
- shutdown(): Promise<void>;
-}
-
-function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
- return new Promise<T>((resolvePromise, reject) => {
- const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms);
- promise.then(
- (value) => {
- clearTimeout(timer);
- resolvePromise(value);
- },
- (err) => {
- clearTimeout(timer);
- reject(err);
- },
- );
- });
-}
-
-function getFilePath(uri: string): string | undefined {
- if (!uri.startsWith("file://")) return undefined;
- return fileURLToPath(uri);
-}
-
-function getSyncKind(capabilities?: ServerCapabilities): number | undefined {
- if (!capabilities) return undefined;
- const sync = capabilities.textDocumentSync;
- if (typeof sync === "number") return sync;
- return sync?.change;
-}
-
-function endPosition(text: string) {
- const lines = text.split(/\r\n|\r|\n/);
- return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 };
-}
-
-function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] {
- const seen = new Set<string>();
- return items.filter((item) => {
- const key = JSON.stringify({
- code: item.code,
- severity: item.severity,
- message: item.message,
- source: item.source,
- range: item.range,
- });
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
-
-function configurationValue(settings: unknown, section?: string): unknown {
- if (!section) return settings ?? null;
- const result = section.split(".").reduce<unknown>((acc, key) => {
- if (!acc || typeof acc !== "object" || !(key in acc)) return undefined;
- return (acc as Record<string, unknown>)[key];
- }, settings);
- return result ?? null;
-}
-
-/**
- * Create and initialize an LSP client over a spawned server's stdio.
- *
- * Performs the full `initialize`/`initialized` handshake (with a 45s timeout),
- * wires push (`textDocument/publishDiagnostics`) and pull
- * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the
- * `workspace/configuration`, `workspaceFolders`, and capability-registration
- * requests servers commonly make, and returns a small client surface used by
- * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`.
- */
-export async function createLspClient(input: {
- serverID: string;
- server: LspServerHandle;
- root: string;
- directory: string;
-}): Promise<LspClient> {
- const { serverID, server, root, directory } = input;
-
- const connection = createMessageConnection(
- new StreamMessageReader(server.process.stdout),
- new StreamMessageWriter(server.process.stdin),
- );
-
- // Server stderr is routine for many tools (luau-lsp logs sourcemap status
- // there). Keep it quiet unless debugging.
- server.process.stderr?.on("data", () => {
- /* swallowed — see opencode: stderr is mostly informational */
- });
-
- // ─── Connection state ───
- const pushDiagnostics = new Map<string, Diagnostic[]>();
- const pullDiagnostics = new Map<string, Diagnostic[]>();
- const published = new Map<string, { at: number; version?: number }>();
- const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
- const registrationListeners = new Set<() => void>();
- const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
- const files: Record<string, { version: number; text: string }> = {};
-
- const mergedDiagnostics = (filePath: string) =>
- dedupeDiagnostics([
- ...(pushDiagnostics.get(filePath) ?? []),
- ...(pullDiagnostics.get(filePath) ?? []),
- ]);
- const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
- pushDiagnostics.set(filePath, next);
- for (const listener of diagnosticListeners) listener({ path: filePath, serverID });
- };
- const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
- pullDiagnostics.set(filePath, next);
- };
- const emitRegistrationChange = () => {
- for (const listener of [...registrationListeners]) listener();
- };
-
- // ─── Notification / request handlers ───
- connection.onNotification(
- "textDocument/publishDiagnostics",
- (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => {
- const filePath = getFilePath(params.uri);
- if (!filePath) return;
- published.set(filePath, {
- at: Date.now(),
- version: typeof params.version === "number" ? params.version : undefined,
- });
- updatePushDiagnostics(filePath, params.diagnostics);
- },
- );
- connection.onRequest("window/workDoneProgress/create", () => null);
- connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => {
- const items = params.items ?? [];
- return items.map((item) => configurationValue(server.initialization, item.section));
- });
- connection.onRequest(
- "client/registerCapability",
- (params: { registrations?: CapabilityRegistration[] }) => {
- const registrations = params.registrations ?? [];
- let changed = false;
- for (const registration of registrations) {
- if (registration.method !== "textDocument/diagnostic") continue;
- diagnosticRegistrations.set(registration.id, registration);
- changed = true;
- }
- if (changed) emitRegistrationChange();
- return null;
- },
- );
- connection.onRequest(
- "client/unregisterCapability",
- (params: { unregisterations?: { id: string; method: string }[] }) => {
- const registrations = params.unregisterations ?? [];
- let changed = false;
- for (const registration of registrations) {
- if (registration.method !== "textDocument/diagnostic") continue;
- diagnosticRegistrations.delete(registration.id);
- changed = true;
- }
- if (changed) emitRegistrationChange();
- return null;
- },
- );
- connection.onRequest("workspace/workspaceFolders", () => [
- { name: "workspace", uri: pathToFileURL(root).href },
- ]);
- connection.onRequest("workspace/diagnostic/refresh", () => null);
- connection.listen();
-
- // ─── Initialize handshake ───
- const initialized = await withTimeout(
- connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
- rootUri: pathToFileURL(root).href,
- processId: server.process.pid ?? null,
- workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }],
- initializationOptions: { ...server.initialization },
- capabilities: {
- window: { workDoneProgress: true },
- workspace: {
- configuration: true,
- didChangeWatchedFiles: { dynamicRegistration: true },
- diagnostics: { refreshSupport: false },
- },
- textDocument: {
- synchronization: { didOpen: true, didChange: true },
- diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
- publishDiagnostics: { versionSupport: false },
- },
- },
- }),
- INITIALIZE_TIMEOUT_MS,
- );
-
- const syncKind = getSyncKind(initialized.capabilities);
- const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider);
-
- await connection.sendNotification("initialized", {});
- if (server.initialization) {
- await connection.sendNotification("workspace/didChangeConfiguration", {
- settings: server.initialization,
- });
- }
-
- // ─── Pull-diagnostics helpers ───
- const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
- const handled = results.some((r) => r.handled);
- const matched = results.some((r) => r.matched);
- if (!handled) return { handled: false, matched: false };
-
- const merged = new Map<string, Diagnostic[]>();
- for (const result of results) {
- for (const [target, items] of result.byFile.entries()) {
- merged.set(target, (merged.get(target) ?? []).concat(items));
- }
- }
- if (matched && !merged.has(filePath)) merged.set(filePath, []);
- for (const [target, items] of merged.entries()) {
- updatePullDiagnostics(target, dedupeDiagnostics(items));
- }
- return { handled, matched };
- };
-
- async function requestDiagnosticReport(
- filePath: string,
- identifier?: string,
- ): Promise<DiagnosticRequestResult> {
- const report = await withTimeout(
- connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
- ...(identifier ? { identifier } : {}),
- textDocument: { uri: pathToFileURL(filePath).href },
- }),
- DIAGNOSTICS_REQUEST_TIMEOUT_MS,
- ).catch(() => null);
- const empty: DiagnosticRequestResult = {
- handled: false,
- matched: false,
- byFile: new Map(),
- };
- if (!report) return empty;
-
- const byFile = new Map<string, Diagnostic[]>();
- const push = (target: string, items: Diagnostic[]) => {
- byFile.set(target, (byFile.get(target) ?? []).concat(items));
- };
- let handled = false;
- let matched = false;
- if (Array.isArray(report.items)) {
- push(filePath, report.items);
- handled = true;
- matched = true;
- }
- for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
- const relatedPath = getFilePath(uri);
- if (!relatedPath || !Array.isArray(related.items)) continue;
- push(relatedPath, related.items);
- handled = true;
- matched = matched || relatedPath === filePath;
- }
- return { handled, matched, byFile };
- }
-
- async function requestWorkspaceDiagnosticReport(
- filePath: string,
- identifier?: string,
- ): Promise<DiagnosticRequestResult> {
- const report = await withTimeout(
- connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
- ...(identifier ? { identifier } : {}),
- previousResultIds: [],
- }),
- DIAGNOSTICS_REQUEST_TIMEOUT_MS,
- ).catch(() => null);
- if (!report) return { handled: false, matched: false, byFile: new Map() };
-
- const byFile = new Map<string, Diagnostic[]>();
- let matched = false;
- for (const item of report.items ?? []) {
- const relatedPath = item.uri ? getFilePath(item.uri) : undefined;
- if (!relatedPath || !Array.isArray(item.items)) continue;
- byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items));
- matched = matched || relatedPath === filePath;
- }
- return { handled: true, matched, byFile };
- }
-
- function documentPullState() {
- const documentRegistrations = [...diagnosticRegistrations.values()].filter(
- (r) => r.registerOptions?.workspaceDiagnostics !== true,
- );
- return {
- documentIdentifiers: [
- ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
- ],
- supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
- };
- }
-
- function workspacePullState() {
- const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
- (r) => r.registerOptions?.workspaceDiagnostics === true,
- );
- return {
- workspaceIdentifiers: [
- ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
- ],
- supported: workspaceRegistrations.length > 0,
- };
- }
-
- const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
- results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0);
-
- async function requestDiagnostics(
- filePath: string,
- requests: Promise<DiagnosticRequestResult>[],
- done: (results: DiagnosticRequestResult[]) => boolean,
- ) {
- if (!requests.length) return { handled: false, matched: false };
- const results: DiagnosticRequestResult[] = [];
- return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => {
- let pending = requests.length;
- let resolved = false;
- const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
- if (resolved) return;
- if (!force && !done(results)) return;
- resolved = true;
- resolvePromise(merged);
- };
- for (const request of requests) {
- request.then((result) => {
- results.push(result);
- pending -= 1;
- const merged = mergeResults(filePath, results);
- finish(merged);
- if (pending === 0) finish(merged, true);
- });
- }
- });
- }
-
- async function requestDocumentDiagnostics(filePath: string) {
- const state = documentPullState();
- if (!state.supported) return { handled: false, matched: false };
- return requestDiagnostics(
- filePath,
- [
- requestDiagnosticReport(filePath),
- ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
- ],
- (results) => hasCurrentFileDiagnostics(filePath, results),
- );
- }
-
- async function requestFullDiagnostics(filePath: string) {
- const documentState = documentPullState();
- const workspaceState = workspacePullState();
- if (!documentState.supported && !workspaceState.supported) {
- return { handled: false, matched: false };
- }
- return mergeResults(
- filePath,
- await Promise.all([
- ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
- ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
- ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
- ...workspaceState.workspaceIdentifiers.map((id) =>
- requestWorkspaceDiagnosticReport(filePath, id),
- ),
- ]),
- );
- }
-
- function waitForRegistrationChange(timeout: number) {
- if (timeout <= 0) return Promise.resolve(false);
- return new Promise<boolean>((resolvePromise) => {
- let finished = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
- const finish = (result: boolean) => {
- if (finished) return;
- finished = true;
- if (timer) clearTimeout(timer);
- registrationListeners.delete(listener);
- resolvePromise(result);
- };
- const listener = () => finish(true);
- registrationListeners.add(listener);
- timer = setTimeout(() => finish(false), timeout);
- });
- }
-
- function waitForFreshPush(request: {
- path: string;
- version: number;
- after: number;
- timeout: number;
- }) {
- if (request.timeout <= 0) return Promise.resolve(false);
- return new Promise<boolean>((resolvePromise) => {
- let finished = false;
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
- let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
- let unsub: (() => void) | undefined;
- const finish = (result: boolean) => {
- if (finished) return;
- finished = true;
- if (debounceTimer) clearTimeout(debounceTimer);
- if (timeoutTimer) clearTimeout(timeoutTimer);
- unsub?.();
- resolvePromise(result);
- };
- const schedule = () => {
- const hit = published.get(request.path);
- if (!hit) return;
- if (typeof hit.version === "number" && hit.version !== request.version) return;
- if (hit.at < request.after && hit.version !== request.version) return;
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(
- () => finish(true),
- Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)),
- );
- };
- timeoutTimer = setTimeout(() => finish(false), request.timeout);
- const listener = (event: { path: string; serverID: string }) => {
- if (event.path !== request.path || event.serverID !== serverID) return;
- schedule();
- };
- diagnosticListeners.add(listener);
- unsub = () => diagnosticListeners.delete(listener);
- schedule();
- });
- }
-
- async function waitForDocumentDiagnostics(request: {
- path: string;
- version: number;
- after?: number;
- }) {
- const startedAt = request.after ?? Date.now();
- const pushWait = waitForFreshPush({
- path: request.path,
- version: request.version,
- after: startedAt,
- timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
- });
- while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
- const result = await requestDocumentDiagnostics(request.path);
- if (result.matched) return;
- const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt);
- if (remaining <= 0) return;
- const next = await Promise.race([
- pushWait.then((ready) => (ready ? "push" : "timeout")),
- waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")),
- ]);
- if (next !== "registration") return;
- }
- }
-
- async function waitForFullDiagnostics(request: {
- path: string;
- version: number;
- after?: number;
- }) {
- const startedAt = request.after ?? Date.now();
- const pushWait = waitForFreshPush({
- path: request.path,
- version: request.version,
- after: startedAt,
- timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
- });
- while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
- const result = await requestFullDiagnostics(request.path);
- if (result.handled || result.matched) return;
- const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt);
- if (remaining <= 0) return;
- const next = await Promise.race([
- pushWait.then((ready) => (ready ? "push" : "timeout")),
- waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")),
- ]);
- if (next !== "registration") return;
- }
- }
-
- const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p));
-
- // ─── Public surface ───
- const client: LspClient = {
- serverID,
- root,
- connection,
- async notifyOpen(path: string) {
- const filePath = normalize(path);
- const text = await readFile(filePath, "utf8");
- const languageId = languageIdForExtension(extname(filePath));
- const uri = pathToFileURL(filePath).href;
- const document = files[filePath];
-
- if (document !== undefined) {
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [{ uri, type: FILE_CHANGE_CHANGED }],
- });
- const next = document.version + 1;
- files[filePath] = { version: next, text };
- await connection.sendNotification("textDocument/didChange", {
- textDocument: { uri, version: next },
- contentChanges:
- syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
- ? [
- {
- range: {
- start: { line: 0, character: 0 },
- end: endPosition(document.text),
- },
- text,
- },
- ]
- : [{ text }],
- });
- return next;
- }
-
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [{ uri, type: FILE_CHANGE_CREATED }],
- });
- pushDiagnostics.delete(filePath);
- pullDiagnostics.delete(filePath);
- await connection.sendNotification("textDocument/didOpen", {
- textDocument: { uri, languageId, version: 0, text },
- });
- files[filePath] = { version: 0, text };
- return 0;
- },
- get diagnostics() {
- const result = new Map<string, Diagnostic[]>();
- for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
- result.set(key, mergedDiagnostics(key));
- }
- return result;
- },
- async waitForDiagnostics(request) {
- const normalizedPath = normalize(request.path);
- if (request.mode === "document") {
- await waitForDocumentDiagnostics({
- path: normalizedPath,
- version: request.version,
- after: request.after,
- });
- return;
- }
- await waitForFullDiagnostics({
- path: normalizedPath,
- version: request.version,
- after: request.after,
- });
- },
- async request<T = unknown>(method: string, params: unknown): Promise<T | null> {
- return connection.sendRequest<T>(method, params).catch(() => null);
- },
- async shutdown() {
- try {
- connection.end();
- connection.dispose();
- } catch {
- /* connection may already be closed */
- }
- server.process.kill();
- },
- };
-
- return client;
-}
diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts
deleted file mode 100644
index 1ad4d0f..0000000
--- a/packages/core/src/lsp/diagnostic.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Diagnostic } from "vscode-languageserver-types";
-
-/**
- * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`.
- *
- * LSP positions are 0-based on the wire; we render them 1-based (editor-style)
- * so they line up with what `read_file` shows and what editors report.
- */
-
-/** Max diagnostics rendered per file before truncating with a "… and N more". */
-const MAX_PER_FILE = 20;
-
-const SEVERITY_LABEL: Record<number, string> = {
- 1: "ERROR",
- 2: "WARN",
- 3: "INFO",
- 4: "HINT",
-};
-
-/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */
-export function pretty(diagnostic: Diagnostic): string {
- const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR";
- const line = diagnostic.range.start.line + 1;
- const col = diagnostic.range.start.character + 1;
- return `${severity} [${line}:${col}] ${diagnostic.message}`;
-}
-
-/**
- * Build a `<diagnostics file="…">` block for a file's ERROR-severity
- * diagnostics, or `""` when there are none. Errors only — warnings/info/hints
- * are intentionally omitted so the model is nudged toward the things that
- * actually break the build (matching opencode's behavior).
- */
-export function report(file: string, issues: Diagnostic[]): string {
- const errors = issues.filter((item) => item.severity === 1);
- if (errors.length === 0) return "";
- const limited = errors.slice(0, MAX_PER_FILE);
- const more = errors.length - MAX_PER_FILE;
- const suffix = more > 0 ? `\n... and ${more} more` : "";
- return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`;
-}
diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts
deleted file mode 100644
index fd43c2f..0000000
--- a/packages/core/src/lsp/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// LSP (Language Server Protocol) integration.
-//
-// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.<id>]`
-// block (see `LspServerConfig` in `../types`). There is no builtin server
-// registry and no auto-download. The primary model-facing surface is
-// diagnostics-on-write (the host passes a write hook that calls `touchFile` +
-// `report`); an on-demand `lsp` tool exposes hover/definition/references too.
-
-export {
- createLspClient,
- type Diagnostic,
- type LspClient,
- type LspServerHandle,
-} from "./client.js";
-export { pretty, report } from "./diagnostic.js";
-export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js";
-export { LspManager } from "./manager.js";
-export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js";
diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts
deleted file mode 100644
index 3e9fe68..0000000
--- a/packages/core/src/lsp/language.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * File-extension → LSP `languageId` map.
- *
- * The LSP `textDocument/didOpen` notification carries a `languageId` string
- * that tells the server how to parse the document. This table is a trimmed
- * port of opencode's `lsp/language.ts`, with one critical addition for this
- * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension,
- * which standard Lua tooling does not recognise — luau-lsp expects the
- * `"luau"` languageId.
- *
- * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown
- * extensions fall back to `"plaintext"` at the call site.
- */
-export const LANGUAGE_EXTENSIONS: Record<string, string> = {
- // Luau (Roblox) — the reason this module exists. Keep first for visibility.
- ".luau": "luau",
- ".lua": "lua",
- // A pragmatic subset of common languages, mirroring opencode's table so a
- // user can point an arbitrary LSP server at this codebase and have the
- // right languageId reported.
- ".c": "c",
- ".cpp": "cpp",
- ".cc": "cpp",
- ".cxx": "cpp",
- ".h": "c",
- ".hpp": "cpp",
- ".cs": "csharp",
- ".css": "css",
- ".dart": "dart",
- ".go": "go",
- ".html": "html",
- ".htm": "html",
- ".java": "java",
- ".js": "javascript",
- ".jsx": "javascriptreact",
- ".json": "json",
- ".jsonc": "jsonc",
- ".kt": "kotlin",
- ".kts": "kotlin",
- ".md": "markdown",
- ".markdown": "markdown",
- ".php": "php",
- ".py": "python",
- ".rb": "ruby",
- ".rs": "rust",
- ".scss": "scss",
- ".sass": "sass",
- ".sh": "shellscript",
- ".bash": "shellscript",
- ".zsh": "shellscript",
- ".sql": "sql",
- ".svelte": "svelte",
- ".swift": "swift",
- ".toml": "toml",
- ".ts": "typescript",
- ".tsx": "typescriptreact",
- ".mts": "typescript",
- ".cts": "typescript",
- ".vue": "vue",
- ".xml": "xml",
- ".yaml": "yaml",
- ".yml": "yaml",
- ".zig": "zig",
-};
-
-/**
- * Resolve the LSP `languageId` for a file path's extension, falling back to
- * `"plaintext"` when the extension is unknown.
- */
-export function languageIdForExtension(extension: string): string {
- return LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
-}
diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts
deleted file mode 100644
index db8b68e..0000000
--- a/packages/core/src/lsp/manager.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import { extname } from "node:path";
-import { createLspClient, type Diagnostic, type LspClient } from "./client.js";
-import type { ResolvedLspServer } from "./server.js";
-
-/**
- * Process-wide owner of LSP client lifecycles.
- *
- * Clients are keyed by `root + serverID` and spawned lazily on the first file
- * that matches a server's extensions, then reused. Concurrent spawns for the
- * same key are de-duplicated via an in-flight map, and servers that fail to
- * start are remembered in `broken` so we don't spawn-spam. Modeled on
- * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery.
- *
- * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a
- * tab's working-directory config (`resolveServersFromConfig`) and pass them in
- * alongside the `root`. This keeps per-working-directory config out of the
- * manager while letting it own all the long-lived processes for the process.
- */
-export class LspManager {
- private clients = new Map<string, LspClient>();
- private spawning = new Map<string, Promise<LspClient | undefined>>();
- private broken = new Set<string>();
-
- private key(root: string, serverID: string): string {
- return `${root}\u0000${serverID}`;
- }
-
- private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] {
- const extension = extname(file) || file;
- return servers.filter(
- (server) => server.extensions.length === 0 || server.extensions.includes(extension),
- );
- }
-
- /**
- * True if any provided server is configured to attach to this file's
- * extension (regardless of whether it has spawned yet). Used to decide
- * whether an LSP operation is even applicable to a file.
- */
- hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean {
- return this.serversForFile(file, servers).length > 0;
- }
-
- /**
- * Get (spawning if needed) all clients that should attach to `file` at
- * `root`. Spawn failures are swallowed (logged via `broken`) and simply
- * yield fewer clients — callers degrade gracefully to "no diagnostics".
- */
- async getClients(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- }): Promise<LspClient[]> {
- const { file, root, servers } = input;
- const matching = this.serversForFile(file, servers);
- const result: LspClient[] = [];
-
- for (const server of matching) {
- const key = this.key(root, server.id);
- if (this.broken.has(key)) continue;
-
- const existing = this.clients.get(key);
- if (existing) {
- result.push(existing);
- continue;
- }
-
- const inflight = this.spawning.get(key);
- if (inflight) {
- const client = await inflight;
- if (client) result.push(client);
- continue;
- }
-
- const task = this.spawn(server, root, key);
- this.spawning.set(key, task);
- task.finally(() => {
- if (this.spawning.get(key) === task) this.spawning.delete(key);
- });
- const client = await task;
- if (client) result.push(client);
- }
-
- return result;
- }
-
- private async spawn(
- server: ResolvedLspServer,
- root: string,
- key: string,
- ): Promise<LspClient | undefined> {
- let handle: ReturnType<ResolvedLspServer["spawn"]>;
- try {
- handle = server.spawn(root);
- } catch (err) {
- this.broken.add(key);
- console.warn(
- `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
- );
- return undefined;
- }
-
- // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH)
- // emits `error` on the child process; mark broken so we don't retry it.
- handle.process.on("error", (err) => {
- this.broken.add(key);
- console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`);
- });
-
- try {
- const client = await createLspClient({
- serverID: server.id,
- server: handle,
- root,
- directory: root,
- });
- // A racing caller may have created the same client; prefer the
- // existing one and discard ours.
- const existing = this.clients.get(key);
- if (existing) {
- await client.shutdown();
- return existing;
- }
- this.clients.set(key, client);
- return client;
- } catch (err) {
- this.broken.add(key);
- try {
- handle.process.kill();
- } catch {
- /* already dead */
- }
- console.warn(
- `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
- );
- return undefined;
- }
- }
-
- /**
- * Open/sync a file with its clients and (optionally) wait for diagnostics
- * to settle. `mode: "document"` waits for the file's own diagnostics;
- * `"full"` also waits on workspace diagnostics; omitted just syncs.
- */
- async touchFile(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- mode?: "document" | "full";
- }): Promise<void> {
- const clients = await this.getClients(input);
- await Promise.all(
- clients.map(async (client) => {
- const after = Date.now();
- const version = await client.notifyOpen(input.file);
- if (!input.mode) return;
- await client.waitForDiagnostics({
- path: input.file,
- version,
- mode: input.mode,
- after,
- });
- }),
- ).catch((err) => {
- console.warn(
- `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- }
-
- /**
- * Merged diagnostics for a single file across all of its clients, keyed by
- * absolute file path. Includes related-file diagnostics a client surfaced
- * (e.g. workspace pulls), so the result map may contain more than `file`.
- */
- getDiagnostics(input: {
- root: string;
- servers: ResolvedLspServer[];
- file: string;
- }): Record<string, Diagnostic[]> {
- const results: Record<string, Diagnostic[]> = {};
- const matching = this.serversForFile(input.file, input.servers);
- for (const server of matching) {
- const client = this.clients.get(this.key(input.root, server.id));
- if (!client) continue;
- for (const [path, diags] of client.diagnostics.entries()) {
- results[path] = (results[path] ?? []).concat(diags);
- }
- }
- return results;
- }
-
- /**
- * Run a positional LSP request (hover/definition/references/etc.) against
- * every client for the file and flatten the (non-null) results. `line`/
- * `character` are 0-based here — the caller converts from editor 1-based.
- */
- async request(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- method: string;
- params: Record<string, unknown>;
- }): Promise<unknown[]> {
- const clients = await this.getClients(input);
- const results = await Promise.all(
- clients.map((client) => client.request(input.method, input.params)),
- );
- return results.filter((r) => r !== null && r !== undefined);
- }
-
- /** Shut down every live client and clear all state. */
- async shutdownAll(): Promise<void> {
- const clients = [...this.clients.values()];
- this.clients.clear();
- this.spawning.clear();
- this.broken.clear();
- await Promise.all(clients.map((client) => client.shutdown().catch(() => {})));
- }
-}
diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts
deleted file mode 100644
index 1fb002e..0000000
--- a/packages/core/src/lsp/server.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
-import type { LspServerConfig } from "../types/index.js";
-import type { LspServerHandle } from "./client.js";
-
-/**
- * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml`
- * `[lsp.<id>]` entry. Config-driven only — dispatch ships no builtin server
- * registry and performs no auto-download (unlike opencode). The declared
- * executable (`command[0]`) must already be on PATH.
- */
-export interface ResolvedLspServer {
- id: string;
- /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */
- extensions: string[];
- /** Launch the server over stdio rooted at `root`. */
- spawn(root: string): LspServerHandle;
-}
-
-/**
- * Spawn a child process for an LSP server over stdio. Inherits `process.env`
- * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate)
- * and merges any `env` from the server config on top.
- */
-function spawnServer(
- command: string[],
- cwd: string,
- env: Record<string, string> | undefined,
- initialization: Record<string, unknown> | undefined,
-): LspServerHandle {
- const [cmd, ...args] = command;
- if (!cmd) throw new Error("LSP server command is empty");
- const proc = spawn(cmd, args, {
- cwd,
- env: { ...process.env, ...env },
- stdio: ["pipe", "pipe", "pipe"],
- }) as ChildProcessWithoutNullStreams;
- return {
- process: proc,
- ...(initialization ? { initialization } : {}),
- };
-}
-
-/**
- * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable
- * servers. Disabled entries are dropped. Entries with no `command`/`extensions`
- * are skipped defensively (the config validator already enforces these, but we
- * guard here too so a hand-built config object can't crash the manager).
- */
-export function resolveServersFromConfig(
- lsp: Record<string, LspServerConfig> | undefined,
-): ResolvedLspServer[] {
- if (!lsp) return [];
- const servers: ResolvedLspServer[] = [];
- for (const [id, entry] of Object.entries(lsp)) {
- if (entry.disabled) continue;
- if (!entry.command || entry.command.length === 0) continue;
- if (!entry.extensions || entry.extensions.length === 0) continue;
- const command = entry.command;
- const env = entry.env;
- const initialization = entry.initialization;
- servers.push({
- id,
- extensions: entry.extensions,
- spawn: (root: string) => spawnServer(command, root, env, initialization),
- });
- }
- return servers;
-}
diff --git a/packages/core/src/models/attachments.ts b/packages/core/src/models/attachments.ts
deleted file mode 100644
index 5c98db4..0000000
--- a/packages/core/src/models/attachments.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-// Validation + limits for multimodal user attachments (images / PDFs).
-//
-// Kept dependency-free (no DB / `bun:sqlite` import) so both the API layer
-// (`/chat` request validation) and any future caller can share the exact same
-// allowlist and size/count ceilings. The limits mirror Anthropic's documented
-// vision/PDF API constraints (the only image-capable providers Dispatch maps),
-// so a request that passes here won't be rejected by the provider for size.
-
-import type { UserAttachmentPart, UserContentPart } from "../types/index.js";
-
-/** Accepted image media types. */
-export const ACCEPTED_IMAGE_MEDIA_TYPES = [
- "image/png",
- "image/jpeg",
- "image/webp",
- "image/gif",
-] as const;
-
-/** Accepted document media types. */
-export const ACCEPTED_PDF_MEDIA_TYPE = "application/pdf";
-
-/** Every media type we accept as an attachment. */
-export const ACCEPTED_ATTACHMENT_MEDIA_TYPES = [
- ...ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
-] as const;
-
-/** Per-image byte ceiling (Anthropic: 5 MB/image). */
-export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
-
-/** Per-PDF byte ceiling (Anthropic: 32 MB/PDF). */
-export const MAX_PDF_BYTES = 32 * 1024 * 1024;
-
-/** Max attachments per message (Anthropic: 20 images/request). */
-export const MAX_ATTACHMENTS = 20;
-
-/**
- * Total attachment payload ceiling for a single request (decoded bytes). Bounds
- * the overall request size even when each individual file is within its limit.
- */
-export const MAX_TOTAL_ATTACHMENT_BYTES = 32 * 1024 * 1024;
-
-/** Whether a media type is an accepted image type. */
-export function isImageMediaType(mediaType: string): boolean {
- return (ACCEPTED_IMAGE_MEDIA_TYPES as readonly string[]).includes(mediaType);
-}
-
-/** Whether a media type is the accepted PDF type. */
-export function isPdfMediaType(mediaType: string): boolean {
- return mediaType === ACCEPTED_PDF_MEDIA_TYPE;
-}
-
-/** Whether a media type is an accepted attachment type at all. */
-export function isAcceptedAttachmentMediaType(mediaType: string): boolean {
- return (ACCEPTED_ATTACHMENT_MEDIA_TYPES as readonly string[]).includes(mediaType);
-}
-
-/**
- * Decoded byte length of a base64 string, computed WITHOUT allocating the
- * decoded buffer. Tolerates an optional `data:<mediaType>;base64,` prefix and
- * any embedded whitespace/newlines. Returns 0 for an empty/whitespace string.
- */
-export function base64ByteLength(b64: string): number {
- // Strip a data-URI prefix if present.
- const comma = b64.indexOf(",");
- const body = b64.startsWith("data:") && comma !== -1 ? b64.slice(comma + 1) : b64;
- let len = 0;
- let pad = 0;
- for (let i = 0; i < body.length; i++) {
- const ch = body.charCodeAt(i);
- // Skip whitespace (space, \t, \n, \r).
- if (ch === 32 || ch === 9 || ch === 10 || ch === 13) continue;
- len++;
- if (body[i] === "=") pad++;
- }
- if (len === 0) return 0;
- // 4 base64 chars → 3 bytes, minus padding.
- return Math.floor((len * 3) / 4) - pad;
-}
-
-export type AttachmentValidationError =
- | { code: "unsupported-type"; mediaType: string }
- | { code: "image-too-large"; mediaType: string; bytes: number; limit: number }
- | { code: "pdf-too-large"; bytes: number; limit: number }
- | { code: "too-many"; count: number; limit: number }
- | { code: "total-too-large"; bytes: number; limit: number }
- | { code: "empty"; mediaType: string };
-
-export interface AttachmentValidationResult {
- ok: boolean;
- errors: AttachmentValidationError[];
-}
-
-/** Extract just the attachment parts from a mixed content list. */
-function attachmentsOf(content: UserContentPart[]): UserAttachmentPart[] {
- return content.filter((p): p is UserAttachmentPart => p.type === "attachment");
-}
-
-/**
- * Validate the attachments in a multimodal user content list against the
- * media-type allowlist and the size/count ceilings. Pure: never throws,
- * collects every violation so the caller can report them all at once.
- *
- * Text parts are ignored (always valid). An empty content list is valid (it's
- * just a text-only message expressed as parts).
- */
-export function validateUserContent(content: UserContentPart[]): AttachmentValidationResult {
- const errors: AttachmentValidationError[] = [];
- const attachments = attachmentsOf(content);
-
- if (attachments.length > MAX_ATTACHMENTS) {
- errors.push({ code: "too-many", count: attachments.length, limit: MAX_ATTACHMENTS });
- }
-
- let total = 0;
- for (const att of attachments) {
- if (!isAcceptedAttachmentMediaType(att.mediaType)) {
- errors.push({ code: "unsupported-type", mediaType: att.mediaType });
- continue;
- }
- const bytes = base64ByteLength(att.data);
- total += bytes;
- if (bytes === 0) {
- errors.push({ code: "empty", mediaType: att.mediaType });
- continue;
- }
- if (isPdfMediaType(att.mediaType)) {
- if (bytes > MAX_PDF_BYTES) {
- errors.push({ code: "pdf-too-large", bytes, limit: MAX_PDF_BYTES });
- }
- } else if (bytes > MAX_IMAGE_BYTES) {
- errors.push({
- code: "image-too-large",
- mediaType: att.mediaType,
- bytes,
- limit: MAX_IMAGE_BYTES,
- });
- }
- }
-
- if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
- errors.push({ code: "total-too-large", bytes: total, limit: MAX_TOTAL_ATTACHMENT_BYTES });
- }
-
- return { ok: errors.length === 0, errors };
-}
-
-/** Convenience: does the content list contain at least one attachment? */
-export function hasAttachments(content: UserContentPart[] | undefined | null): boolean {
- return !!content && content.some((p) => p.type === "attachment");
-}
diff --git a/packages/core/src/models/catalog.ts b/packages/core/src/models/catalog.ts
deleted file mode 100644
index ac310b1..0000000
--- a/packages/core/src/models/catalog.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
-import { dirname } from "node:path";
-
-/**
- * models.dev-backed model catalog. Resolves a model's MAXIMUM context window
- * (`limit.context`) dynamically from the public models.dev API, mirroring how
- * opencode determines per-model context limits — no hardcoded table.
- *
- * The catalog is fetched once, cached on disk with a short TTL, and reused. On
- * fetch failure we fall back to a stale-but-present cache so the lookup keeps
- * working offline. Lookups never throw: an unknown/unreachable model resolves
- * to `null`, which the UI renders as "max unknown".
- */
-
-/** Shape of the slice of models.dev's `/api.json` we consume. */
-interface ModelsDevModel {
- limit?: {
- context?: number;
- output?: number;
- };
- /**
- * Input/output modalities the model accepts. We read `input` to decide
- * whether the model can take image / pdf attachments. Absent on older
- * catalog entries — treated as "unknown" (capability resolves to `null`).
- */
- modalities?: {
- input?: string[];
- output?: string[];
- };
-}
-
-interface ModelsDevProvider {
- id: string;
- models: Record<string, ModelsDevModel | undefined>;
-}
-
-type ModelsDevCatalog = Record<string, ModelsDevProvider | undefined>;
-
-/** Where models.dev's API lives. Overridable for tests / private mirrors. */
-const MODELS_URL = process.env.DISPATCH_MODELS_URL || "https://models.dev";
-
-/** Disk cache path (reuses the repo's `/tmp/dispatch` convention). */
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-/** How long a cached catalog stays fresh before we re-fetch. */
-const CACHE_TTL_MS = 5 * 60 * 1000;
-
-/** Network timeout for the catalog fetch. */
-const FETCH_TIMEOUT_MS = 10_000;
-
-/**
- * After a failed fetch we memoize the fallback for this long before retrying,
- * so a sustained outage doesn't make every lookup hang on a fresh timeout.
- */
-const FETCH_PENALTY_MS = 60_000;
-
-/**
- * Dispatch provider id → models.dev provider ids to search, in priority order.
- * We only support Claude-backed providers (per product scope). `anthropic` and
- * `opencode-anthropic` are both Claude; we try the first-party `anthropic`
- * catalog first, then the `opencode` gateway catalog as a fallback.
- */
-const PROVIDER_MAP: Record<string, string[]> = {
- anthropic: ["anthropic", "opencode"],
- "opencode-anthropic": ["anthropic", "opencode"],
-};
-
-/** In-process memoized catalog promise (one fetch/parse per TTL window). */
-let cached: { catalog: ModelsDevCatalog; fetchedAt: number } | null = null;
-let inflight: Promise<ModelsDevCatalog> | null = null;
-
-function readDiskCache(): { catalog: ModelsDevCatalog; mtimeMs: number } | null {
- try {
- const stat = statSync(CACHE_PATH);
- const text = readFileSync(CACHE_PATH, "utf-8");
- return { catalog: JSON.parse(text) as ModelsDevCatalog, mtimeMs: stat.mtimeMs };
- } catch {
- return null;
- }
-}
-
-function writeDiskCache(text: string): void {
- try {
- mkdirSync(dirname(CACHE_PATH), { recursive: true });
- // Write-then-rename so a concurrent reader never sees a half-written
- // file (rename is atomic on the same filesystem). The temp name is
- // process-scoped to avoid two writers clobbering each other's temp.
- const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
- writeFileSync(tmp, text, "utf-8");
- renameSync(tmp, CACHE_PATH);
- } catch {
- // Best-effort: a read-only /tmp shouldn't break lookups.
- }
-}
-
-async function fetchCatalog(): Promise<ModelsDevCatalog> {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
- try {
- const res = await fetch(`${MODELS_URL}/api.json`, { signal: controller.signal });
- if (!res.ok) throw new Error(`models.dev returned HTTP ${res.status}`);
- const text = await res.text();
- const catalog = JSON.parse(text) as ModelsDevCatalog;
- writeDiskCache(text);
- return catalog;
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Load the models.dev catalog, preferring in-process memo, then a fresh disk
- * cache, then a network fetch. On network failure, falls back to any stale
- * disk cache; if nothing is available, returns an empty catalog.
- */
-export async function getModelsCatalog(): Promise<ModelsDevCatalog> {
- if (process.env.DISPATCH_DISABLE_MODELS_FETCH) {
- const disk = readDiskCache();
- return disk?.catalog ?? {};
- }
-
- const now = Date.now();
- if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.catalog;
-
- // Fresh disk cache satisfies the request without a network round-trip.
- const disk = readDiskCache();
- if (disk && now - disk.mtimeMs < CACHE_TTL_MS) {
- // Inherit the file's mtime as `fetchedAt` so loading a disk cache into
- // a fresh process doesn't reset its TTL (which would otherwise double
- // the worst-case staleness across process boundaries).
- cached = { catalog: disk.catalog, fetchedAt: disk.mtimeMs };
- return disk.catalog;
- }
-
- if (!inflight) {
- inflight = fetchCatalog()
- .then((catalog) => {
- cached = { catalog, fetchedAt: Date.now() };
- return catalog;
- })
- .catch((err) => {
- // Network failed — serve a stale cache if we have one.
- console.warn(
- `dispatch: failed to fetch models.dev catalog: ${err instanceof Error ? err.message : String(err)}`,
- );
- const fallback = disk?.catalog ?? ({} as ModelsDevCatalog);
- // Memoize the fallback with a short "penalty" TTL so a sustained
- // outage doesn't make every lookup hang on a fresh 10s timeout.
- // `fetchedAt` is backdated so the memo expires after FETCH_PENALTY_MS.
- cached = {
- catalog: fallback,
- fetchedAt: Date.now() - CACHE_TTL_MS + FETCH_PENALTY_MS,
- };
- return fallback;
- })
- .finally(() => {
- inflight = null;
- });
- }
- return inflight;
-}
-
-/**
- * Resolve a model's maximum context window (in tokens) for the given Dispatch
- * provider + model id. Returns `null` when the provider is unsupported, the
- * model is unknown, or the catalog is unavailable — callers should render that
- * as "max unknown" (no denominator / percentage).
- */
-export async function resolveContextLimit(
- provider: string,
- modelId: string,
-): Promise<number | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const ctx = catalog[providerId]?.models?.[modelId]?.limit?.context;
- if (typeof ctx === "number" && ctx > 0) return ctx;
- }
- return null;
-}
-
-/**
- * Image / PDF input capabilities for a model, resolved from the models.dev
- * catalog's `modalities.input` list.
- */
-export interface ModelInputCapabilities {
- /** Model accepts image input (vision). */
- image: boolean;
- /** Model accepts PDF/document input. */
- pdf: boolean;
-}
-
-/**
- * Resolve whether a model accepts image / pdf input for the given Dispatch
- * provider + model id. Returns `null` when the capability is UNKNOWN — i.e. the
- * provider is unsupported/unmapped, the model is absent from the catalog, the
- * entry predates the `modalities` field, or the catalog is unavailable. Callers
- * should treat `null` as "can't verify" (optimistic allow) rather than a
- * definitive "no", so a temporary catalog outage never disables a known-good
- * vision model.
- *
- * A non-null result means the catalog DID describe the model's input modalities
- * — `{ image, pdf }` then reflects exactly what it advertises (a definitive
- * yes/no for each).
- */
-export async function resolveModelCapabilities(
- provider: string,
- modelId: string,
-): Promise<ModelInputCapabilities | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const input = catalog[providerId]?.models?.[modelId]?.modalities?.input;
- if (Array.isArray(input)) {
- return { image: input.includes("image"), pdf: input.includes("pdf") };
- }
- }
- return null;
-}
-
-/** Test-only: reset the in-process memo so a test can re-exercise loading. */
-export function __resetCatalogCacheForTests(): void {
- cached = null;
- inflight = null;
-}
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
deleted file mode 100644
index 15d1ee2..0000000
--- a/packages/core/src/models/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export {
- ACCEPTED_ATTACHMENT_MEDIA_TYPES,
- ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
- type AttachmentValidationError,
- type AttachmentValidationResult,
- base64ByteLength,
- hasAttachments,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- validateUserContent,
-} from "./attachments.js";
-export {
- getModelsCatalog,
- type ModelInputCapabilities,
- resolveContextLimit,
- resolveModelCapabilities,
-} from "./catalog.js";
-export { ModelRegistry } from "./registry.js";
diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts
deleted file mode 100644
index 4a24a51..0000000
--- a/packages/core/src/models/registry.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import type { KeyDefinition, KeyState } from "../types/index.js";
-
-export class ModelRegistry {
- private keyStates: Map<string, KeyState>;
- private keyOrder: string[];
-
- constructor(keys: KeyDefinition[]) {
- this.keyStates = new Map();
- this.keyOrder = [];
- this._initConfig(keys, new Map());
- }
-
- private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void {
- this.keyOrder = keys.map((k) => k.id);
-
- const newStates = new Map<string, KeyState>();
- for (const key of keys) {
- const existing = existingStates.get(key.id);
- if (existing) {
- // Preserve existing state but update definition
- newStates.set(key.id, { ...existing, definition: key });
- } else {
- newStates.set(key.id, { definition: key, status: "active" });
- }
- }
- this.keyStates = newStates;
- }
-
- getKeys(): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter((state): state is KeyState => state !== undefined);
- }
-
- markKeyExhausted(keyId: string, error?: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- this.keyStates.set(keyId, {
- ...state,
- status: "exhausted",
- lastError: error,
- exhaustedAt: Date.now(),
- });
- }
-
- markKeyActive(keyId: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- const updated: KeyState = {
- definition: state.definition,
- status: "active",
- };
- this.keyStates.set(keyId, updated);
- }
-
- hasAvailableKey(provider: string): boolean {
- for (const state of this.keyStates.values()) {
- if (state.definition.provider === provider && state.status === "active") {
- return true;
- }
- }
- return false;
- }
-
- allKeysExhausted(): boolean {
- for (const state of this.keyStates.values()) {
- if (state.status === "active") {
- return false;
- }
- }
- return true;
- }
-
- updateConfig(keys: KeyDefinition[]): void {
- this._initConfig(keys, this.keyStates);
- }
-
- // Internal: get ordered key states for a specific provider
- getOrderedKeysForProvider(provider: string): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter(
- (state): state is KeyState => state !== undefined && state.definition.provider === provider,
- );
- }
-}
diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts
deleted file mode 100644
index 49e6ff4..0000000
--- a/packages/core/src/notifications/config.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-// Persisted ntfy config — single global JSON blob under one settings key.
-//
-// One global config (no per-user split): the rest of Dispatch's settings
-// table is global today (cf. `title_model_*`, `perm_*`), so notification
-// config follows the same pattern.
-
-import { deleteSetting, getSetting, setSetting } from "../db/settings.js";
-import type { NotificationEventType, NtfyConfig } from "./types.js";
-import { NTFY_DEFAULT_EVENTS, NTFY_EVENT_TYPES } from "./types.js";
-
-export const NTFY_CONFIG_KEY = "ntfy_config";
-
-/** Defaults returned when nothing is persisted yet. */
-export function defaultNtfyConfig(): NtfyConfig {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: { ...NTFY_DEFAULT_EVENTS },
- notifySubagents: false,
- };
-}
-
-/**
- * Normalize an arbitrary parsed JSON value into a complete `NtfyConfig`.
- * Tolerant of missing / unexpected fields so a config from an older build
- * never throws — missing event toggles fall back to defaults.
- */
-export function normalizeNtfyConfig(raw: unknown): NtfyConfig {
- const base = defaultNtfyConfig();
- if (!raw || typeof raw !== "object") return base;
- const obj = raw as Record<string, unknown>;
- const out: NtfyConfig = {
- enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled,
- topic: typeof obj.topic === "string" ? obj.topic : base.topic,
- authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken,
- events: { ...base.events },
- notifySubagents:
- typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents,
- };
- const rawEvents = obj.events;
- if (rawEvents && typeof rawEvents === "object") {
- const evObj = rawEvents as Record<string, unknown>;
- for (const key of NTFY_EVENT_TYPES) {
- const v = evObj[key];
- if (typeof v === "boolean") out.events[key as NotificationEventType] = v;
- }
- }
- return out;
-}
-
-/** Load the persisted config (or defaults if none/corrupt). */
-export function loadNtfyConfig(): NtfyConfig {
- const raw = getSetting(NTFY_CONFIG_KEY);
- if (!raw) return defaultNtfyConfig();
- try {
- return normalizeNtfyConfig(JSON.parse(raw));
- } catch {
- return defaultNtfyConfig();
- }
-}
-
-/** Persist a complete config (after server-side normalization). */
-export function saveNtfyConfig(config: NtfyConfig): void {
- const normalized = normalizeNtfyConfig(config);
- setSetting(NTFY_CONFIG_KEY, JSON.stringify(normalized));
-}
-
-/** Wipe the persisted config (revert to defaults on next load). */
-export function clearNtfyConfig(): void {
- deleteSetting(NTFY_CONFIG_KEY);
-}
-
-/** Strip the auth token from a config before returning it over the API. */
-export function redactNtfyConfig(config: NtfyConfig): NtfyConfig & { hasAuthToken: boolean } {
- return { ...config, authToken: "", hasAuthToken: config.authToken.trim().length > 0 };
-}
diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts
deleted file mode 100644
index 01ce00c..0000000
--- a/packages/core/src/notifications/dispatcher.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-// NotificationDispatcher — turns high-level Dispatch events into
-// `sendNtfy(...)` calls, gated by the persisted user config.
-//
-// The dispatcher is transport-agnostic at the `notify(event)` interface
-// boundary: only `sendNtfy` is wired today, but adding another transport
-// (email, webhook, etc.) means changing this one file, not the call sites.
-//
-// All sends are non-blocking (fire-and-forget). A 10-second timeout in
-// `sendNtfy` bounds the worst case; the dispatcher additionally guards
-// every send in a try/catch so a transport bug can never propagate into
-// the agent loop.
-
-import { loadNtfyConfig } from "./config.js";
-import { type FetchLike, sendNtfy } from "./ntfy.js";
-import type { NotificationEvent, NtfyConfig } from "./types.js";
-
-/** Minimal shape of an `AgentManager`-style event stream we hook into. */
-export interface AgentEventSource {
- onEvent(
- listener: (event: { type: string; tabId: string; [key: string]: unknown }) => void,
- ): () => void;
-}
-
-/** Minimal shape of a `PermissionManager`-style prompt source. */
-export interface PermissionPromptSource {
- onPromptAdded(
- listener: (prompt: { id: string; permission: string; description: string }) => void,
- ): () => void;
-}
-
-/** Look up a human-readable tab title for nicer notification text. */
-export type TabTitleLookup = (tabId: string) => string | null;
-
-/**
- * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no
- * parent) and `undefined` when the lookup can't be performed (no DB, tab
- * not found). Both non-strings cause the dispatcher to fall back to
- * "treat as top-level" to avoid silently dropping notifications when the
- * lookup is broken.
- */
-export type TabParentLookup = (tabId: string) => string | null | undefined;
-
-export interface DispatcherOptions {
- /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */
- loadConfig?: () => NtfyConfig;
- /** Override the transport (tests). Defaults to the real `sendNtfy`. */
- send?: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
- /** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */
- fetchImpl?: FetchLike;
- /** Look up a tab title for richer titles. */
- getTabTitle?: TabTitleLookup;
- /**
- * Look up a tab's `parentTabId`. Used to honour the
- * `notifySubagents` config flag — when false, `turn-completed` /
- * `turn-error` from subagent tabs (those with a parent) are
- * suppressed.
- */
- getTabParentId?: TabParentLookup;
- /**
- * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit
- * the whole pending list on every change, so dedupe is essential.
- */
- dedupeWindowMs?: number;
-}
-
-export class NotificationDispatcher {
- private loadConfig: () => NtfyConfig;
- private send: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
- private getTabTitle: TabTitleLookup | undefined;
- private getTabParentId: TabParentLookup | undefined;
- private dedupeWindowMs: number;
- /** Recently-sent dedupeKey → expiresAt epoch ms. */
- private recentlySent = new Map<string, number>();
- private unsubs: Array<() => void> = [];
-
- constructor(opts: DispatcherOptions = {}) {
- this.loadConfig = opts.loadConfig ?? loadNtfyConfig;
- this.send =
- opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined));
- this.getTabTitle = opts.getTabTitle;
- this.getTabParentId = opts.getTabParentId;
- this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000;
- }
-
- /**
- * Single internal entry point — every public hook funnels through here.
- * Public so a future caller can synthesize an arbitrary notification
- * (e.g. a CLI `dispatch notify` command); kept narrow.
- */
- notify(event: NotificationEvent): void {
- const config = this.loadConfig();
- if (!config.enabled) return;
- if (!config.events[event.type]) return;
- if (event.dedupeKey && this.isDuplicate(event.dedupeKey)) return;
- if (event.dedupeKey) this.markSent(event.dedupeKey);
-
- // Fire-and-forget: never await, never throw.
- try {
- void Promise.resolve(this.send(config, event)).catch((err) => {
- console.warn(
- `[ntfy] send failed for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- } catch (err) {
- // Guard the synchronous portion of `send` too.
- console.warn(
- `[ntfy] dispatch threw for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
-
- /**
- * Hook into an `AgentManager`-style event stream.
- *
- * Maps:
- * - `done` → `turn-completed`
- * - `error` → `turn-error`
- * - `tab-created` → `agent-spawned` (only top-level user-agent tabs)
- *
- * `status` events are ignored — they fire on every transition and we'd
- * either spam or duplicate the `done`/`error` notifications.
- *
- * Turn events from subagent tabs are suppressed when
- * `config.notifySubagents === false` (the default). A parent agent
- * spawning 8 subagents would otherwise produce 9 "Turn complete"
- * pushes per round; almost always noise. Permission prompts are NOT
- * gated this way — a subagent's permission request still needs human
- * input to proceed, so suppressing those would silently hang the
- * subagent.
- */
- attachToAgentManager(source: AgentEventSource): () => void {
- const unsub = source.onEvent((event) => {
- if (event.type === "done") {
- if (this.isSubagentSuppressed(event.tabId)) return;
- this.notify(this.buildTurnCompleted(event));
- } else if (event.type === "error") {
- if (this.isSubagentSuppressed(event.tabId)) return;
- this.notify(this.buildTurnError(event));
- } else if (event.type === "tab-created") {
- const ev = event as unknown as {
- tabId: string;
- id: string;
- title: string;
- parentTabId: string | null;
- agentSlug?: string | null;
- };
- // Only notify for top-level user-agent tabs spawned via `summon`.
- // Filtering on `agentSlug` skips "blank" new tabs the user opened
- // manually, which would be noisy.
- if (ev.parentTabId === null && ev.agentSlug) {
- this.notify(this.buildAgentSpawned(ev));
- }
- }
- });
- this.unsubs.push(unsub);
- return unsub;
- }
-
- /** Hook into a `PermissionManager`-style prompt source. */
- attachToPermissionManager(source: PermissionPromptSource): () => void {
- const unsub = source.onPromptAdded((prompt) => {
- this.notify(this.buildPermissionRequired(prompt));
- });
- this.unsubs.push(unsub);
- return unsub;
- }
-
- /** Release all hooks acquired via `attachTo*`. */
- dispose(): void {
- for (const u of this.unsubs) {
- try {
- u();
- } catch {
- // best-effort
- }
- }
- this.unsubs = [];
- this.recentlySent.clear();
- }
-
- // ─── Event builders (internal) ────────────────────────────────
-
- private buildTurnCompleted(event: { tabId: string }): NotificationEvent {
- const tabLabel = this.tabLabel(event.tabId);
- return {
- type: "turn-completed",
- title: `Turn complete — ${tabLabel}`,
- message: `Assistant finished a turn in ${tabLabel}.`,
- tabId: event.tabId,
- };
- }
-
- private buildTurnError(event: {
- tabId: string;
- error?: unknown;
- statusCode?: unknown;
- }): NotificationEvent {
- const tabLabel = this.tabLabel(event.tabId);
- const errText = typeof event.error === "string" ? event.error : "Unknown error";
- const statusText = typeof event.statusCode === "number" ? ` (status ${event.statusCode})` : "";
- return {
- type: "turn-error",
- title: `Turn failed — ${tabLabel}`,
- message: `${errText}${statusText}`,
- tabId: event.tabId,
- };
- }
-
- private buildPermissionRequired(prompt: {
- id: string;
- permission: string;
- description: string;
- }): NotificationEvent {
- return {
- type: "permission-required",
- title: `Permission required: ${prompt.permission}`,
- message: prompt.description || `Agent is requesting ${prompt.permission} permission.`,
- // Permission prompts can re-emit (e.g. another prompt arrives while
- // this one is still pending) — dedupe on the prompt id.
- dedupeKey: `permission:${prompt.id}`,
- };
- }
-
- private buildAgentSpawned(ev: {
- tabId: string;
- id: string;
- title: string;
- agentSlug?: string | null;
- }): NotificationEvent {
- return {
- type: "agent-spawned",
- title: `User agent spawned — ${ev.agentSlug ?? "agent"}`,
- message: ev.title,
- tabId: ev.tabId ?? ev.id,
- };
- }
-
- private tabLabel(tabId: string): string {
- const title = this.getTabTitle?.(tabId);
- if (title?.trim()) return title.trim();
- return `tab ${tabId.slice(0, 8)}`;
- }
-
- /**
- * Returns true when this `tabId` belongs to a subagent AND the user has
- * opted out of subagent turn notifications. On lookup failure
- * (`getTabParentId` returns `undefined` or throws) we err on the side
- * of "not a subagent" — better to over-notify than to silently drop
- * legitimate top-level events when the DB is briefly unreadable.
- */
- private isSubagentSuppressed(tabId: string): boolean {
- const config = this.loadConfig();
- if (config.notifySubagents) return false;
- if (!this.getTabParentId) return false;
- let parent: string | null | undefined;
- try {
- parent = this.getTabParentId(tabId);
- } catch {
- return false;
- }
- // Only a non-empty string parent id means "this tab is a subagent".
- return typeof parent === "string" && parent.length > 0;
- }
-
- // ─── Dedupe helpers ───────────────────────────────────────────
-
- private isDuplicate(key: string): boolean {
- const expires = this.recentlySent.get(key);
- if (expires === undefined) return false;
- if (expires <= Date.now()) {
- this.recentlySent.delete(key);
- return false;
- }
- return true;
- }
-
- private markSent(key: string): void {
- // Lazy-evict expired entries when the map gets large.
- if (this.recentlySent.size > 256) {
- const now = Date.now();
- for (const [k, exp] of this.recentlySent) {
- if (exp <= now) this.recentlySent.delete(k);
- }
- }
- this.recentlySent.set(key, Date.now() + this.dedupeWindowMs);
- }
-}
diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts
deleted file mode 100644
index d1e7891..0000000
--- a/packages/core/src/notifications/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// @dispatch/core — ntfy.sh push notifications
-
-export {
- clearNtfyConfig,
- defaultNtfyConfig,
- loadNtfyConfig,
- NTFY_CONFIG_KEY,
- normalizeNtfyConfig,
- redactNtfyConfig,
- saveNtfyConfig,
-} from "./config.js";
-export {
- type AgentEventSource,
- type DispatcherOptions,
- NotificationDispatcher,
- type PermissionPromptSource,
- type TabParentLookup,
- type TabTitleLookup,
-} from "./dispatcher.js";
-export {
- buildNtfyUrl,
- type FetchLike,
- NTFY_BASE_URL,
- type NtfySendResult,
- sendNtfy,
-} from "./ntfy.js";
-export {
- type NotificationEvent,
- type NotificationEventType,
- NTFY_DEFAULT_EVENTS,
- NTFY_DEFAULT_PRIORITIES,
- NTFY_DEFAULT_TAGS,
- NTFY_EVENT_TYPES,
- type NtfyConfig,
- type NtfyPriority,
-} from "./types.js";
diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts
deleted file mode 100644
index eb5de9e..0000000
--- a/packages/core/src/notifications/ntfy.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-// ntfy.sh HTTP transport.
-//
-// ntfy's API is a simple POST to `https://ntfy.sh/<topic>` with the body
-// as the message and metadata passed via HTTP headers:
-// Title: notification title
-// Priority: 1..5 (3 = default)
-// Tags: comma-separated emoji shortcodes
-// Click: URL opened when the notification is tapped
-//
-// The server is hardcoded to the public ntfy.sh instance; the user only
-// configures a topic name. We intentionally use `fetch` directly — no
-// SDK, no extra deps.
-
-import type { NotificationEvent, NtfyConfig } from "./types.js";
-import { NTFY_DEFAULT_PRIORITIES, NTFY_DEFAULT_TAGS } from "./types.js";
-
-export interface NtfySendResult {
- ok: boolean;
- status?: number;
- error?: string;
-}
-
-/**
- * Lightweight fetch shape so callers (and tests) can inject a mock without
- * pulling in the DOM `fetch` type from a `Headers` instance.
- */
-export type FetchLike = (
- input: string,
- init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
-) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>;
-
-/** Base URL of the public ntfy.sh server. */
-export const NTFY_BASE_URL = "https://ntfy.sh";
-
-/**
- * Build the publish URL for a topic name.
- *
- * No client-side validation of the topic content: ntfy.sh's accepted
- * character set has changed over time and a regex here only locks users
- * out of legitimate topics. The topic is URL-encoded so the resulting
- * URL is always syntactically valid; if ntfy rejects the name the HTTP
- * error surfaces on the first send / `Send test`.
- */
-export function buildNtfyUrl(topic: string): string {
- return `${NTFY_BASE_URL}/${encodeURIComponent(topic.trim())}`;
-}
-
-/**
- * Send a single notification to the configured ntfy topic.
- *
- * Fire-and-forget at call sites: the dispatcher uses
- * `void sendNtfy(...).catch(...)` so a slow/broken ntfy server never blocks
- * a turn. We still return a structured result so the explicit
- * `POST /notifications/test` route can surface failures back to the UI.
- *
- * Pure with respect to `config` / `event` — no DB, no module state.
- */
-export async function sendNtfy(
- config: NtfyConfig,
- event: NotificationEvent,
- fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike,
- timeoutMs = 10_000,
-): Promise<NtfySendResult> {
- if (!config.enabled) return { ok: false, error: "Notifications are disabled" };
- if (!config.topic.trim()) return { ok: false, error: "Topic is required" };
- const targetUrl = buildNtfyUrl(config.topic);
-
- const priority = event.priority ?? NTFY_DEFAULT_PRIORITIES[event.type] ?? 3;
- const baseTags = event.tags ?? NTFY_DEFAULT_TAGS[event.type] ?? [];
- const tags = [...baseTags];
- if (event.tabId) {
- // Short, ASCII-only tag so ntfy's comma-separated header parser is happy.
- tags.push(`tab-${event.tabId.slice(0, 8)}`);
- }
-
- const headers: Record<string, string> = {
- // ntfy is tolerant of non-ASCII in the Title header but many proxies
- // aren't — sanitizeHeader strips CR/LF/control chars (injection guard)
- // and leaves UTF-8 in place. Body is sent verbatim as UTF-8.
- Title: sanitizeHeader(event.title),
- Priority: String(priority),
- "Content-Type": "text/plain; charset=utf-8",
- };
- if (tags.length > 0) headers.Tags = tags.map(sanitizeHeader).join(",");
- if (event.clickUrl) headers.Click = sanitizeHeader(event.clickUrl);
- const authValue = buildAuthHeaderValue(config.authToken);
- if (authValue) headers.Authorization = authValue;
-
- // Per-request abort so a hung server doesn't pin a Bun worker forever.
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
-
- try {
- const res = await fetchImpl(targetUrl, {
- method: "POST",
- headers,
- body: event.message,
- signal: controller.signal,
- });
- if (!res.ok) {
- const text = await safeReadText(res);
- return {
- ok: false,
- status: res.status,
- error: `ntfy responded ${res.status} ${res.statusText ?? ""}: ${text}`.trim(),
- };
- }
- return { ok: true, status: res.status };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- return { ok: false, error: msg };
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Build the `Authorization` header value from the user-configured token.
- *
- * - Empty/blank ⇒ no header.
- * - Already starts with `Bearer ` / `Basic ` / etc. (RFC 7235 scheme + space)
- * ⇒ used verbatim. Lets users paste a complete header for Basic auth or
- * any other scheme their private ntfy server supports.
- * - Otherwise ⇒ prefixed with `Bearer ` (the common case).
- */
-function buildAuthHeaderValue(rawToken: string): string | null {
- const trimmed = (rawToken ?? "").trim();
- if (!trimmed) return null;
- if (/^[A-Za-z][A-Za-z0-9._~+/-]*\s+\S/.test(trimmed)) {
- // Already includes a scheme token (e.g. "Bearer xyz", "Basic dXNlcjpw").
- return sanitizeHeader(trimmed);
- }
- return `Bearer ${sanitizeHeader(trimmed)}`;
-}
-
-function sanitizeHeader(value: string): string {
- // Strip CR/LF (header injection guard) and other control chars, then trim.
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
- return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim();
-}
-
-async function safeReadText(res: { text(): Promise<string> }): Promise<string> {
- try {
- const t = await res.text();
- return t.length > 200 ? `${t.slice(0, 200)}…` : t;
- } catch {
- return "";
- }
-}
diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts
deleted file mode 100644
index ef6c059..0000000
--- a/packages/core/src/notifications/types.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-// ntfy.sh push notifications — types
-
-/**
- * Catalog of notification-worthy events.
- *
- * Kept intentionally small and stable: each entry is something a human
- * actually wants pushed to their phone. New event types should be added
- * with a sensible default (`NTFY_DEFAULT_EVENTS`) and a mapping in the
- * dispatcher.
- */
-export type NotificationEventType =
- | "turn-completed"
- | "turn-error"
- | "permission-required"
- | "agent-spawned";
-
-/** ntfy priority levels (1=min … 5=max). */
-export type NtfyPriority = 1 | 2 | 3 | 4 | 5;
-
-/**
- * A single notification request. Synthesised by the dispatcher from a
- * higher-level event source (AgentManager / PermissionManager); fed to
- * the ntfy transport.
- *
- * `dedupeKey` lets the dispatcher suppress duplicate sends (e.g. the
- * permission system re-emits the pending list on every change).
- */
-export interface NotificationEvent {
- type: NotificationEventType;
- /** Notification title (short). */
- title: string;
- /** Notification body. */
- message: string;
- /** Optional ntfy tags (emoji shortcodes — e.g. `["white_check_mark"]`). */
- tags?: string[];
- /** Optional priority override. Defaults are per-event-type. */
- priority?: NtfyPriority;
- /** Optional URL the notification deep-links to when tapped. */
- clickUrl?: string;
- /** Origin tab id (informational; included in tags as `tab:<short>`). */
- tabId?: string;
- /**
- * Stable key for suppressing duplicates. Same key + same type within a
- * short window ⇒ dropped silently.
- */
- dedupeKey?: string;
-}
-
-/**
- * Persisted ntfy configuration. Lives in the `settings` table under a
- * single key (`ntfy_config`) — one global config, matching the codebase's
- * existing single-user assumption (cf. `title_model_*`, `perm_*`).
- *
- * - `enabled` — master switch. Off ⇒ dispatcher never sends.
- * - `topic` — bare ntfy.sh topic name, e.g. `my-secret-topic`. The
- * server is hardcoded to https://ntfy.sh; the user only picks a topic.
- * Missing ⇒ dispatcher never sends.
- * - `authToken` — optional bearer token (rarely needed against ntfy.sh
- * directly; preserved for users behind an auth-protected proxy).
- * - `events` — per-event-type enable map. Missing entries default to OFF
- * so a newly-added event type doesn't silently start firing.
- * - `notifySubagents` — when false (default), `turn-completed` and
- * `turn-error` notifications from subagent tabs (tabs with a
- * `parentTabId`) are suppressed. A parent agent that spawns 8
- * subagents would otherwise push 9 "Turn complete" notifications per
- * round — usually noise. `permission-required` is NOT gated: even a
- * subagent's permission prompt needs a human tap to proceed.
- * `agent-spawned` is already top-level-only by construction.
- */
-export interface NtfyConfig {
- enabled: boolean;
- topic: string;
- authToken: string;
- events: Record<NotificationEventType, boolean>;
- notifySubagents: boolean;
-}
-
-/** All event types this build knows about (the source of truth for UI). */
-export const NTFY_EVENT_TYPES: NotificationEventType[] = [
- "turn-completed",
- "turn-error",
- "permission-required",
- "agent-spawned",
-];
-
-/** Default per-event-type toggles. */
-export const NTFY_DEFAULT_EVENTS: Record<NotificationEventType, boolean> = {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
-};
-
-/** Default priority per event type (when the event itself doesn't override). */
-export const NTFY_DEFAULT_PRIORITIES: Record<NotificationEventType, NtfyPriority> = {
- "turn-completed": 3,
- "turn-error": 4,
- "permission-required": 4,
- "agent-spawned": 2,
-};
-
-/** Default tag (emoji) per event type. */
-export const NTFY_DEFAULT_TAGS: Record<NotificationEventType, string[]> = {
- "turn-completed": ["white_check_mark"],
- "turn-error": ["rotating_light"],
- "permission-required": ["lock"],
- "agent-spawned": ["sparkles"],
-};
diff --git a/packages/core/src/permission/evaluate.ts b/packages/core/src/permission/evaluate.ts
deleted file mode 100644
index 7a0d235..0000000
--- a/packages/core/src/permission/evaluate.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { PermissionRule, Ruleset } from "./index.js";
-import { Wildcard } from "./wildcard.js";
-
-export function evaluate(
- permission: string,
- pattern: string,
- ...rulesets: Ruleset[]
-): PermissionRule {
- const flat = ([] as PermissionRule[]).concat(...rulesets);
- const match = flat.findLast(
- (rule) => Wildcard.match(rule.permission, permission) && Wildcard.match(rule.pattern, pattern),
- );
- if (match) return match;
- return { action: "ask", permission, pattern };
-}
diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts
deleted file mode 100644
index bf6efaf..0000000
--- a/packages/core/src/permission/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-export interface PermissionRule {
- permission: string;
- pattern: string;
- action: "allow" | "deny" | "ask";
-}
-
-export type Ruleset = PermissionRule[];
-
-export type PermissionReply = "once" | "always" | "reject";
-
-export interface PermissionRequest {
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-export interface PermissionChecker {
- ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply>;
- getPending(): Array<{ id: string; request: PermissionRequest }>;
-}
-
-export { evaluate } from "./evaluate.js";
-export { PermissionService } from "./service.js";
-export { Wildcard } from "./wildcard.js";
diff --git a/packages/core/src/permission/service.ts b/packages/core/src/permission/service.ts
deleted file mode 100644
index 1c8c6f0..0000000
--- a/packages/core/src/permission/service.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { evaluate } from "./evaluate.js";
-import type { PermissionReply, PermissionRequest, PermissionRule, Ruleset } from "./index.js";
-
-export class PermissionService {
- private pending: Map<
- string,
- {
- request: PermissionRequest;
- resolve: (reply: PermissionReply) => void;
- reject: (err: Error) => void;
- }
- > = new Map();
- private approved: Ruleset = [];
- private idCounter = 0;
-
- approve(rules: PermissionRule[]): void {
- this.approved.push(...rules);
- }
-
- async ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply> {
- // Evaluate against all rulesets + approved (approved overrides) for ALL patterns
- const patterns = request.always.length > 0 ? request.always : ["*"];
- const results = patterns.map((pattern) =>
- evaluate(request.permission, pattern, ...rulesets, this.approved),
- );
-
- // Any deny → deny
- const denied = results.find((r) => r.action === "deny");
- if (denied) {
- throw new Error(`Permission denied: ${request.permission} ${denied.pattern}`);
- }
-
- // All allow → allow
- if (results.every((r) => r.action === "allow")) {
- return "once";
- }
-
- // action === "ask" — create a pending request
- const id = String(++this.idCounter);
- return new Promise<PermissionReply>((resolve, reject) => {
- this.pending.set(id, { request, resolve, reject });
- });
- }
-
- reply(id: string, reply: PermissionReply): void {
- if (reply === "reject") {
- // Reject cascade — reject all pending
- for (const [pendingId, entry] of this.pending) {
- entry.reject(new Error(`Permission rejected: ${entry.request.permission}`));
- this.pending.delete(pendingId);
- }
- return;
- }
-
- this.resolvePending(id, reply);
- }
-
- getPending(): Array<{ id: string; request: PermissionRequest }> {
- return Array.from(this.pending.entries()).map(([id, { request }]) => ({ id, request }));
- }
-
- private resolvePending(id: string, reply: PermissionReply): void {
- const entry = this.pending.get(id);
- if (!entry) return;
-
- if (reply === "always") {
- // Add rules for each pattern in request.patterns
- for (const pattern of entry.request.patterns) {
- this.approved.push({ permission: entry.request.permission, pattern, action: "allow" });
- }
- }
-
- entry.resolve(reply);
- this.pending.delete(id);
- }
-}
diff --git a/packages/core/src/permission/wildcard.ts b/packages/core/src/permission/wildcard.ts
deleted file mode 100644
index 7874680..0000000
--- a/packages/core/src/permission/wildcard.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const Wildcard = {
- match(pattern: string, value: string): boolean {
- // Escape regex special chars except * and ?
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
- // Convert wildcards
- const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
- const regex = new RegExp(`^${regexStr}$`, "s");
- return regex.test(value);
- },
-};
diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts
deleted file mode 100644
index a3fac70..0000000
--- a/packages/core/src/skills/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export {
- createSkillsWatcher,
- getSkillByName,
- loadSkills,
- resolveSkillsForAgent,
-} from "./loader.js";
-export { parseSkillFile } from "./parser.js";
diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts
deleted file mode 100644
index 5d043dd..0000000
--- a/packages/core/src/skills/loader.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import chokidar from "chokidar";
-import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js";
-import { parseSkillFile } from "./parser.js";
-
-// ─── Internal Helpers ────────────────────────────────────────────
-
-/**
- * Recursively scan a directory for .md skill files.
- * The `directory` field on each skill is the relative path from `baseDir` to the file's parent.
- * Skips the `agents/` subdirectory (handled separately).
- */
-function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] {
- if (!fs.existsSync(baseDir)) return [];
-
- const results: SkillDefinition[] = [];
-
- function walk(dir: string) {
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return;
- }
-
- for (const entry of entries) {
- const fullPath = path.join(dir, entry.name);
-
- if (entry.isDirectory()) {
- // Skip agents/ at the top level (handled by loadAgentMappings)
- const relFromBase = path.relative(baseDir, fullPath);
- if (relFromBase === "agents") continue;
- walk(fullPath);
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
- const relDir = path.relative(baseDir, dir);
- // relDir is "" for root, "general" for general/, "general/webapps" for nested
- const directory = relDir === "." ? "" : relDir;
- try {
- const content = fs.readFileSync(fullPath, "utf-8");
- const skill = parseSkillFile(fullPath, content, scope, directory);
- results.push(skill);
- } catch {
- // Skip unreadable files
- }
- }
- }
- }
-
- walk(baseDir);
- return results;
-}
-
-function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] {
- if (!fs.existsSync(agentsDir)) {
- return [];
- }
-
- const results: AgentSkillMapping[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(agentsDir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".txt")) {
- continue;
- }
-
- const fileName = entry.name;
- let isOrchestrator = false;
- let agentType: string;
-
- if (fileName.endsWith(".o.txt")) {
- isOrchestrator = true;
- agentType = fileName.slice(0, -6); // remove ".o.txt"
- } else {
- agentType = fileName.slice(0, -4); // remove ".txt"
- }
-
- const filePath = path.join(agentsDir, fileName);
- try {
- const content = fs.readFileSync(filePath, "utf-8");
- const skills = content
- .split("\n")
- .map((line) => line.trim())
- .filter((line) => line.length > 0 && !line.startsWith("#"));
-
- results.push({ agentType, isOrchestrator, skills, scope });
- } catch {
- // Skip unreadable mapping files
- }
- }
-
- return results;
-}
-
-// ─── Public API ──────────────────────────────────────────────────
-
-export function loadSkills(projectDir: string): {
- skills: SkillDefinition[];
- mappings: AgentSkillMapping[];
-} {
- const globalBase = path.join(os.homedir(), ".skills");
- const projectBase = path.join(projectDir, ".skills");
-
- const skills: SkillDefinition[] = [];
- const mappings: AgentSkillMapping[] = [];
-
- // 1. Scan all global skills recursively (skipping agents/)
- skills.push(...scanSkillsRecursive(globalBase, "global"));
-
- // 2. Scan all project skills recursively (skipping agents/)
- skills.push(...scanSkillsRecursive(projectBase, "project"));
-
- // 3. Agent mappings — global then project
- mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global"));
- mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project"));
-
- return { skills, mappings };
-}
-
-export function resolveSkillsForAgent(
- agentType: string,
- isOrchestrator: boolean,
- skills: SkillDefinition[],
- mappings: AgentSkillMapping[],
-): SkillDefinition[] {
- // Helper: project overrides global for same-named skills
- const dedupeByName = (list: SkillDefinition[]): SkillDefinition[] => {
- const seen = new Map<string, SkillDefinition>();
- for (const skill of list) {
- const existing = seen.get(skill.name);
- if (!existing || skill.scope === "project") {
- seen.set(skill.name, skill);
- }
- }
- return Array.from(seen.values());
- };
-
- // All default-directory skills (global first, then project — dedupe preserves project)
- const defaultSkills = skills.filter((s) => s.directory === "default");
-
- // Skills mapped to this agent type
- const relevantMappings = mappings.filter(
- (m) => m.agentType === agentType && m.isOrchestrator === isOrchestrator,
- );
-
- // Gather agent-specific skills in order: global mappings first, then project
- const globalMappings = relevantMappings.filter((m) => m.scope === "global");
- const projectMappings = relevantMappings.filter((m) => m.scope === "project");
-
- const agentSkillNames: string[] = [];
- for (const mapping of [...globalMappings, ...projectMappings]) {
- for (const skillFile of mapping.skills) {
- const skillName = path.basename(skillFile, path.extname(skillFile));
- agentSkillNames.push(skillName);
- }
- }
-
- const agentSpecificSkills = agentSkillNames
- .map((name) => getSkillByName(name, skills, "project"))
- .filter((s): s is SkillDefinition => s !== undefined);
-
- const combined = [...defaultSkills, ...agentSpecificSkills];
- return dedupeByName(combined);
-}
-
-export function getSkillByName(
- name: string,
- skills: SkillDefinition[],
- preferScope?: SkillScope,
-): SkillDefinition | undefined {
- const matches = skills.filter((s) => s.name === name);
- if (matches.length === 0) {
- return undefined;
- }
-
- if (preferScope) {
- const preferred = matches.find((s) => s.scope === preferScope);
- if (preferred) {
- return preferred;
- }
- }
-
- // Default: project takes precedence
- const projectMatch = matches.find((s) => s.scope === "project");
- return projectMatch ?? matches[0];
-}
-
-export function createSkillsWatcher(
- projectDir: string,
- onChange: (result: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }) => void,
-): { close(): void } {
- const globalBase = path.join(os.homedir(), ".skills");
- const projectBase = path.join(projectDir, ".skills");
-
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const reload = () => {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- const result = loadSkills(projectDir);
- onChange(result);
- }, 300);
- };
-
- const watchPaths = [globalBase, projectBase];
-
- const watcher = chokidar.watch(watchPaths, {
- ignoreInitial: true,
- persistent: true,
- });
-
- watcher.on("add", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- watcher.on("change", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- watcher.on("unlink", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close();
- },
- };
-}
diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts
deleted file mode 100644
index bd1205e..0000000
--- a/packages/core/src/skills/parser.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as path from "node:path";
-import { parse } from "smol-toml";
-import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js";
-
-const FRONTMATTER_DELIMITER = "+++";
-
-export function parseSkillFile(
- filePath: string,
- content: string,
- scope: SkillScope,
- directory: SkillDirectory,
-): SkillDefinition {
- const defaultName = path.basename(filePath, path.extname(filePath));
-
- let name = defaultName;
- let description = "";
- let tags: string[] = [];
- let body = content;
-
- // Check for TOML frontmatter
- const trimmed = content.trimStart();
- if (trimmed.startsWith(FRONTMATTER_DELIMITER)) {
- const afterOpen = trimmed.slice(FRONTMATTER_DELIMITER.length);
- // Find the closing +++
- const closeIndex = afterOpen.indexOf(FRONTMATTER_DELIMITER);
- if (closeIndex !== -1) {
- const tomlSource = afterOpen.slice(0, closeIndex);
- body = afterOpen.slice(closeIndex + FRONTMATTER_DELIMITER.length);
-
- try {
- const frontmatter = parse(tomlSource);
-
- if (typeof frontmatter.name === "string") {
- name = frontmatter.name;
- }
- if (typeof frontmatter.description === "string") {
- description = frontmatter.description;
- }
- if (Array.isArray(frontmatter.tags)) {
- tags = (frontmatter.tags as unknown[]).filter((t): t is string => typeof t === "string");
- }
- } catch {
- // Malformed TOML — fall through with defaults
- }
- }
- }
-
- // Trim leading newline from body (handles both LF and CRLF)
- body = body.replace(/^\r?\n/, "");
-
- return {
- name,
- description,
- tags,
- content: body,
- scope,
- source: filePath,
- directory,
- };
-}
diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts
deleted file mode 100644
index 1aaba8e..0000000
--- a/packages/core/src/tools/bash-arity.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-// Hardcoded dictionary of well-known commands and their arity
-// (number of tokens that form the "human-understandable" prefix)
-const ARITY: Record<string, number> = {
- git: 2, // "git checkout", "git commit", etc.
- npm: 3, // "npm run dev", "npm install -g"
- docker: 2, // "docker compose", "docker build"
- kubectl: 2, // "kubectl get", "kubectl apply"
- bun: 2, // "bun install", "bun test"
- cargo: 2, // "cargo build", "cargo test"
- go: 2, // "go build", "go test"
- python: 2, // "python -m", "python script.py"
- python3: 2,
- pip: 2,
- pip3: 2,
- brew: 2,
- apt: 2,
- "apt-get": 2,
- dnf: 2,
- yum: 2,
- pacman: 2,
- systemctl: 2,
- journalctl: 2,
- ssh: 2,
- scp: 2,
- rsync: 2,
- curl: 2, // "curl -X", "curl https://"
- wget: 2,
- tar: 2, // "tar -xzf", "tar -czf"
- zip: 2,
- unzip: 2,
- chown: 2,
- chmod: 2,
- mount: 2,
- umount: 2,
- // Default: all other commands are arity 1
-};
-
-// Get the normalized prefix for a list of tokens
-export function prefix(tokens: string[]): string[] {
- if (tokens.length === 0) return [];
- const first = tokens[0];
- if (!first) return [];
- const arity = ARITY[first.toLowerCase()];
- if (arity === undefined) {
- return [first];
- }
- return tokens.slice(0, arity);
-}
diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts
deleted file mode 100644
index 0655ad7..0000000
--- a/packages/core/src/tools/key-usage.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-import { z } from "zod";
-import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js";
-import { getAccountUsageWithSource } from "../credentials/claude.js";
-import type { OpencodeUsageReport } from "../credentials/opencode.js";
-import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js";
-import type { KeyState, ToolDefinition } from "../types/index.js";
-
-/**
- * Collaborators the `key_usage` tool needs from the API layer (which owns the
- * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*`
- * hooks default to the real credential fetchers but are injectable so tests can
- * exercise the tool without network or DB access.
- */
-export interface KeyUsageCallbacks {
- /** Current key states from the model registry (definition + active/exhausted status). */
- listKeys(): KeyState[];
- /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */
- listClaudeAccounts(): ClaudeAccount[];
- /**
- * Fetch an anthropic account's usage with provenance (live vs cache).
- * Defaults to `getAccountUsageWithSource`.
- */
- fetchAnthropicUsage?: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- /**
- * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no
- * local cache). Defaults to `fetchOpencodeUsage`.
- */
- fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}
-
-/** A single normalized usage window (5-hour / week / month). */
-interface UsageWindow {
- label: string;
- /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */
- remainingPercent?: number;
- /** Epoch-ms the window resets. Omitted when the source gives no reset time. */
- resetsAt?: number;
-}
-
-/** Fully normalized per-key usage, ready for rendering. */
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- status: "active" | "exhausted";
- lastError?: string;
- exhaustedAt?: number;
- /** Provenance of the usage figures: a fresh live fetch or a cached payload. */
- dataSource?: "live" | "cache";
- /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */
- cachedAt?: number;
- windows: UsageWindow[];
- /** Set when no usage figures could be obtained for an otherwise-supported key. */
- unavailableReason?: string;
- /** Set when the provider has no usage-reporting support. */
- unsupported?: boolean;
-}
-
-function clampPercent(value: number): number {
- if (value < 0) return 0;
- if (value > 100) return 100;
- return value;
-}
-
-/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */
-function toWindow(
- label: string,
- bucket?: { utilization?: number; resetsAt?: number },
-): UsageWindow | null {
- if (!bucket) return null;
- const hasUtil = typeof bucket.utilization === "number";
- const hasReset = typeof bucket.resetsAt === "number";
- if (!hasUtil && !hasReset) return null;
- return {
- label,
- ...(hasUtil
- ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) }
- : {}),
- ...(hasReset ? { resetsAt: bucket.resetsAt } : {}),
- };
-}
-
-function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] {
- const windows: UsageWindow[] = [];
- const fiveHour = toWindow("5-hour", report.fiveHour);
- if (fiveHour) windows.push(fiveHour);
- const week = toWindow("week", report.sevenDay);
- if (week) windows.push(week);
- return windows;
-}
-
-function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] {
- const windows: UsageWindow[] = [];
- const fiveHour = toWindow("5-hour", report.fiveHour);
- if (fiveHour) windows.push(fiveHour);
- const week = toWindow("week", report.weekly);
- if (week) windows.push(week);
- const month = toWindow("month", report.monthly);
- if (month) windows.push(month);
- return windows;
-}
-
-/**
- * Resolve which Claude account backs an `anthropic` key. Matches by key id or by
- * the account's source file (the key's `credentials_file`), falling back to the
- * first available account — mirrors the existing `/models/key-usage` route.
- */
-function matchAnthropicAccount(
- accounts: ClaudeAccount[],
- keyId: string,
- credFile?: string,
-): ClaudeAccount | undefined {
- const matched = accounts.find(
- (a) => a.id === keyId || (credFile != null && a.source === credFile),
- );
- return matched ?? accounts[0];
-}
-
-function iso(ms: number): string {
- return new Date(ms).toISOString();
-}
-
-/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */
-function formatDuration(ms: number): string {
- const totalSec = Math.round(Math.abs(ms) / 1000);
- const days = Math.floor(totalSec / 86400);
- const hours = Math.floor((totalSec % 86400) / 3600);
- const minutes = Math.floor((totalSec % 3600) / 60);
- const parts: string[] = [];
- if (days > 0) parts.push(`${days}d`);
- if (hours > 0) parts.push(`${hours}h`);
- if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
- return parts.join(" ");
-}
-
-function formatRelative(targetMs: number, nowMs: number): string {
- const delta = targetMs - nowMs;
- return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`;
-}
-
-function formatWindow(window: UsageWindow, now: number): string {
- const parts: string[] = [];
- if (typeof window.remainingPercent === "number") {
- parts.push(`${window.remainingPercent}% remaining`);
- }
- if (typeof window.resetsAt === "number") {
- parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`);
- }
- return `${window.label}: ${parts.join(", ")}`;
-}
-
-/**
- * Render normalized usage entries into an AI-friendly text block. Pure — `now`
- * is injected so relative timestamps are deterministic under test.
- */
-export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string {
- if (entries.length === 0) return "No API keys matched.";
-
- const lines: string[] = [];
- lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`);
-
- for (const entry of entries) {
- lines.push("");
- lines.push(`[${entry.keyId}] provider: ${entry.provider}`);
-
- if (entry.status === "exhausted") {
- const since =
- typeof entry.exhaustedAt === "number"
- ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})`
- : "";
- lines.push(`status: EXHAUSTED${since}`);
- if (entry.lastError) lines.push(`last error: ${entry.lastError}`);
- } else {
- lines.push("status: active");
- }
-
- if (entry.unsupported) {
- lines.push(
- `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`,
- );
- continue;
- }
-
- if (entry.dataSource === "live") {
- lines.push("data: live (fetched just now)");
- } else if (entry.dataSource === "cache") {
- lines.push(
- typeof entry.cachedAt === "number"
- ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})`
- : "data: cached (source timestamp unknown)",
- );
- }
-
- for (const window of entry.windows) {
- lines.push(formatWindow(window, now));
- }
-
- if (entry.unavailableReason) {
- lines.push(`usage: unavailable — ${entry.unavailableReason}`);
- }
- }
-
- return lines.join("\n");
-}
-
-async function buildEntry(
- key: KeyState,
- accounts: ClaudeAccount[],
- fetchAnthropic: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>,
- fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>,
-): Promise<KeyUsageEntry> {
- const def = key.definition;
- const entry: KeyUsageEntry = {
- keyId: def.id,
- provider: def.provider,
- status: key.status,
- windows: [],
- ...(key.lastError ? { lastError: key.lastError } : {}),
- ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}),
- };
-
- if (def.provider === "anthropic") {
- const account = matchAnthropicAccount(accounts, def.id, def.credentials_file);
- if (!account) {
- entry.unavailableReason = "no Claude account credentials available for this key";
- return entry;
- }
- let result: ClaudeUsageResult | null = null;
- try {
- result = await fetchAnthropic(account);
- } catch {
- result = null;
- }
- if (!result) {
- entry.unavailableReason = "no live usage data and no cached usage available";
- return entry;
- }
- entry.dataSource = result.source;
- if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt;
- entry.windows = anthropicWindows(result.report);
- if (entry.windows.length === 0) {
- entry.unavailableReason = "usage endpoint returned no window data";
- }
- return entry;
- }
-
- if (def.provider === "opencode-go") {
- let report: OpencodeUsageReport | null = null;
- try {
- report = await fetchOpencode(def.id);
- } catch {
- report = null;
- }
- if (!report) {
- entry.unavailableReason =
- "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)";
- return entry;
- }
- entry.dataSource = "live";
- entry.windows = opencodeWindows(report);
- if (entry.windows.length === 0) {
- entry.unavailableReason = "usage source returned no window data";
- }
- return entry;
- }
-
- entry.unsupported = true;
- return entry;
-}
-
-export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition {
- const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource;
- const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage;
-
- return {
- name: "key_usage",
- description: [
- "Report current usage levels for configured API keys so you can pick a key with",
- "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.",
- "",
- "For each key it returns: provider, active/exhausted status (with the last error when",
- "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where",
- "the provider exposes it), each window's reset timestamp, and whether the figures are",
- "live or served from cache (with the cache's last-fetched time).",
- "",
- "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is",
- "supported for anthropic and opencode-go keys.",
- ].join("\n"),
- parameters: z.object({
- key_id: z
- .string()
- .optional()
- .describe(
- 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.',
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined;
-
- const allKeys = callbacks.listKeys();
- if (allKeys.length === 0) {
- return "No API keys are configured.";
- }
-
- let keys = allKeys;
- if (requestedKeyId) {
- keys = allKeys.filter((k) => k.definition.id === requestedKeyId);
- if (keys.length === 0) {
- const available = allKeys.map((k) => k.definition.id).join(", ");
- return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`;
- }
- }
-
- const accounts = callbacks.listClaudeAccounts();
- const entries: KeyUsageEntry[] = [];
- for (const key of keys) {
- entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode));
- }
-
- return formatKeyUsage(entries, Date.now());
- },
- };
-}
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
deleted file mode 100644
index e003099..0000000
--- a/packages/core/src/tools/list-files.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { readdir } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-export function createListFilesTool(workingDirectory: string): ToolDefinition {
- return {
- name: "list_files",
- description: "List files and directories at a path relative to the working directory.",
- parameters: z.object({
- path: z
- .string()
- .optional()
- .describe("Path to list, relative to the working directory. Defaults to '.'"),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const relPath = (args.path as string | undefined) ?? ".";
- // Canonicalize so a symlink-in-workdir pointing outside is detected.
- // See `canonicalize` in ./path-utils.ts for the resolution semantics.
- const absolutePath = await canonicalize(workingDirectory, relPath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
-
- if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${relPath}" is outside the working directory.`;
- }
-
- try {
- const entries = await readdir(absolutePath, { withFileTypes: true });
- if (entries.length === 0) {
- return "(empty directory)";
- }
- return entries
- .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
- .join("\n");
- } catch (err) {
- return `Error listing files: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts
deleted file mode 100644
index 3842cd4..0000000
--- a/packages/core/src/tools/lsp.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { isAbsolute, resolve } from "node:path";
-import { pathToFileURL } from "node:url";
-import { z } from "zod";
-import { report as reportDiagnostics } from "../lsp/diagnostic.js";
-import type { LspManager } from "../lsp/manager.js";
-import type { ResolvedLspServer } from "../lsp/server.js";
-import type { ToolDefinition } from "../types/index.js";
-
-const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const;
-type Operation = (typeof OPERATIONS)[number];
-
-/**
- * Context the LSP tool needs from the host: the live manager, the tab's
- * effective working directory (used as the LSP `root`), and the servers
- * resolved from that directory's `dispatch.toml`.
- */
-export interface LspToolContext {
- manager: LspManager;
- workingDirectory: string;
- servers: ResolvedLspServer[];
-}
-
-/**
- * On-demand LSP query tool. Exposes diagnostics plus the navigation
- * capabilities (hover/definition/references/documentSymbol) for a file at a
- * position. Gated behind `perm_lsp` by the host.
- *
- * Coordinates are **1-based** in this tool's API (editor-style, matching what
- * `read_file` shows); they are converted to the LSP wire's 0-based positions
- * before the request.
- */
-export function createLspTool(getContext: () => LspToolContext): ToolDefinition {
- return {
- name: "lsp",
- description:
- "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " +
- "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " +
- "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " +
- "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " +
- "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.",
- parameters: z.object({
- operation: z.enum(OPERATIONS).describe("The LSP operation to perform"),
- path: z.string().describe("Path to the file, relative to the working directory"),
- line: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- "Line number, 1-based (as shown in editors). Required for hover/definition/references.",
- ),
- character: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- "Character/column, 1-based (as shown in editors). Required for hover/definition/references.",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const { manager, workingDirectory, servers } = getContext();
- const operation = args.operation as Operation;
- const pathArg = typeof args.path === "string" ? args.path : "";
- if (!pathArg) return "Error: 'path' is required.";
-
- const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg);
-
- if (servers.length === 0) {
- return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml.";
- }
- if (!manager.hasServerForFile(file, servers)) {
- return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`;
- }
-
- // Sync the file so the server has current content, then act.
- await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" });
-
- if (operation === "diagnostics") {
- const all = manager.getDiagnostics({ root: workingDirectory, servers, file });
- const block = reportDiagnostics(file, all[file] ?? []);
- return block || `No errors reported for ${pathArg}.`;
- }
-
- if (operation === "documentSymbol") {
- const uri = pathToFileURL(file).href;
- const results = await manager.request({
- file,
- root: workingDirectory,
- servers,
- method: "textDocument/documentSymbol",
- params: { textDocument: { uri } },
- });
- const flat = results.flat().filter(Boolean);
- return flat.length === 0
- ? `No symbols found in ${pathArg}.`
- : JSON.stringify(flat, null, 2);
- }
-
- // Positional operations need line + character.
- const line = typeof args.line === "number" ? Math.floor(args.line) : undefined;
- const character = typeof args.character === "number" ? Math.floor(args.character) : undefined;
- if (line === undefined || character === undefined) {
- return `Error: '${operation}' requires both 'line' and 'character' (1-based).`;
- }
-
- const uri = pathToFileURL(file).href;
- // Convert editor 1-based → LSP wire 0-based.
- const position = { line: line - 1, character: character - 1 };
- const method =
- operation === "hover"
- ? "textDocument/hover"
- : operation === "definition"
- ? "textDocument/definition"
- : "textDocument/references";
- const params: Record<string, unknown> = {
- textDocument: { uri },
- position,
- ...(operation === "references" ? { context: { includeDeclaration: true } } : {}),
- };
-
- const results = await manager.request({
- file,
- root: workingDirectory,
- servers,
- method,
- params,
- });
- const flat = results.flat().filter(Boolean);
- return flat.length === 0
- ? `No results found for ${operation} at ${pathArg}:${line}:${character}.`
- : JSON.stringify(flat, null, 2);
- },
- };
-}
diff --git a/packages/core/src/tools/path-utils.ts b/packages/core/src/tools/path-utils.ts
deleted file mode 100644
index 3bba0d3..0000000
--- a/packages/core/src/tools/path-utils.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { realpath } from "node:fs/promises";
-import { basename, dirname, join, resolve } from "node:path";
-
-/**
- * Resolve a path to its canonical absolute form, following symlinks at
- * every level.
- *
- * When the leaf does not exist (common for `write_file` creating a new
- * file), walks up to the nearest existing ancestor, canonicalizes that,
- * then re-appends the missing trailing segments — ensuring a symlink in
- * the *middle* of the path is still resolved. Without this, a
- * `workdir/escape-link/new-file.txt` write where `escape-link` points
- * outside the workdir would slip the containment check.
- *
- * Used everywhere we compare a user-supplied path against a trusted
- * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the
- * only reliable way to detect a symlink-in-workdir-pointing-outside
- * escape; lexical-only checks let those through.
- *
- * Argument semantics match `path.resolve(...paths)`: later absolute
- * segments override earlier ones, relative segments are joined.
- */
-export async function canonicalize(...paths: string[]): Promise<string> {
- const lexical = resolve(...paths);
-
- // Fast path: full path exists, realpath resolves all symlinks.
- try {
- return await realpath(lexical);
- } catch {
- // Path doesn't exist — fall through to ancestor walk.
- }
-
- // Walk up until we hit an existing ancestor we can realpath, then
- // re-append the missing trailing segments. This handles cases like
- // write_file creating /workdir/symlink/new/dir/file.txt where the
- // "symlink" segment exists but the rest doesn't.
- let current = lexical;
- const trailing: string[] = [];
- while (true) {
- const parent = dirname(current);
- if (parent === current) break; // hit filesystem root
- trailing.unshift(basename(current));
- try {
- const realParent = await realpath(parent);
- return join(realParent, ...trailing);
- } catch {
- current = parent;
- }
- }
-
- // No existing ancestor (pathological — e.g. the entire mount is gone).
- // Return the lexical path; downstream containment checks will still
- // catch obvious escapes via `..` etc.
- return lexical;
-}
diff --git a/packages/core/src/tools/read-file-slice.ts b/packages/core/src/tools/read-file-slice.ts
deleted file mode 100644
index 0f83bcb..0000000
--- a/packages/core/src/tools/read-file-slice.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-import { SPILL_ROOT } from "./truncate.js";
-
-const DEFAULT_LENGTH = 5000;
-
-export function createReadFileSliceTool(workingDirectory: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description:
- "Read a character-range slice of a single line in a file. Use this when read_file returns a truncated line marker like '[line N truncated, total X chars; use read_file_slice ...]', or when you need precise byte-ish access into a minified file (huge JSON, base64 blob, etc.). For normal line-oriented reading use read_file with offset/limit instead.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory."),
- line: z.number().int().min(1).describe("1-indexed line number to slice into."),
- charOffset: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe("0-indexed character offset within the line. Default: 0 (start of line)."),
- charLength: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Max characters to return from the slice. Default: ${DEFAULT_LENGTH}. The universal tool-output truncator will still spill if the result is huge.`,
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const lineNumber = args.line as number;
- const charOffset =
- typeof args.charOffset === "number" ? Math.max(0, Math.floor(args.charOffset)) : 0;
- const charLength =
- typeof args.charLength === "number"
- ? Math.max(1, Math.floor(args.charLength))
- : DEFAULT_LENGTH;
-
- // Canonicalize all three so symlink-in-workdir escapes are detected.
- // See `canonicalize` in ./path-utils.ts for the resolution semantics.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
- const isUnderWorkdir =
- absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
- const isSpillFile =
- absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
-
- if (!isUnderWorkdir && !isSpillFile) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- let raw: string;
- try {
- raw = await readFile(absolutePath, "utf8");
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return `Error: File "${filePath}" not found.`;
- }
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- const lines = raw.split("\n");
- const trailingNewline = raw.endsWith("\n");
- const totalLines = trailingNewline ? lines.length - 1 : lines.length;
-
- if (lineNumber > totalLines) {
- return `Error: line ${lineNumber} exceeds file length (${totalLines} lines).`;
- }
-
- const line = lines[lineNumber - 1] ?? "";
- const lineLength = line.length;
-
- if (charOffset >= lineLength) {
- return `Error: charOffset ${charOffset} exceeds line length (${lineLength} chars).`;
- }
-
- const sliceEnd = Math.min(charOffset + charLength, lineLength);
- const slice = line.slice(charOffset, sliceEnd);
- const remaining = lineLength - sliceEnd;
-
- const header = `[file: ${filePath} — line ${lineNumber}, chars ${charOffset}-${sliceEnd} of ${lineLength}${remaining > 0 ? ` (${remaining.toLocaleString()} chars remain after this slice)` : ""}]`;
- return `${header}\n${slice}`;
- },
- };
-}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
deleted file mode 100644
index fa82dce..0000000
--- a/packages/core/src/tools/read-file.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-import { MAX_LINES, SPILL_ROOT } from "./truncate.js";
-
-// Per-line truncation: any single line longer than MAX_LINE_CHARS is cut and
-// replaced with a marker indicating the total length. This protects against
-// minified files / base64 blobs / massive single-line JSON. The AI can use
-// `read_file_slice` to inspect a specific char range within a long line.
-const MAX_LINE_CHARS = 2000;
-
-// Aligned with the universal truncator's MAX_LINES so a default-args read
-// returns a response that fits under the truncator's line ceiling. Without
-// this alignment, every default read of a >500-line file got returned by
-// the tool and then immediately spilled by the truncator — wasted work.
-// Char-dense files can still spill via MAX_CHARS, but that's content-
-// dependent rather than guaranteed.
-const DEFAULT_LIMIT = MAX_LINES;
-// Hard cap on lines per request even if `limit` is larger or omitted.
-// Prevents a `read_file(huge.log)` with no params from returning a million
-// lines. The universal truncator at the agent level will spill anything
-// over its own threshold, but this is a tighter first line of defense
-// scoped to the read tool itself.
-const HARD_LIMIT = 5000;
-
-export function createReadFileTool(workingDirectory: string): ToolDefinition {
- return {
- name: "read_file",
- description:
- "Read a file relative to the working directory. Returns up to `limit` lines starting at line `offset` (1-indexed). Lines longer than 2000 chars are truncated mid-line with a marker showing the total length — use the `read_file_slice` tool to read a specific char range within a long line. If the response is still too large, the dispatch tool-output truncator may spill the full content to /tmp/dispatch/tool-results/.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory"),
- offset: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe("1-indexed start line. Default: 1 (start of file)."),
- limit: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Max lines to return. Default: ${DEFAULT_LIMIT}. Hard cap: ${HARD_LIMIT}. Use a small limit when exploring a large file.`,
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const offset = typeof args.offset === "number" ? Math.max(1, Math.floor(args.offset)) : 1;
- const requestedLimit =
- typeof args.limit === "number" ? Math.max(1, Math.floor(args.limit)) : DEFAULT_LIMIT;
- const limit = Math.min(requestedLimit, HARD_LIMIT);
-
- // Canonicalize all three so symlink-in-workdir escapes are detected:
- // a workdir-relative path that resolves through symlinks to /etc must
- // fail the containment check below.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
- const isUnderWorkdir =
- absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
- const isSpillFile =
- absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
-
- if (!isUnderWorkdir && !isSpillFile) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- let raw: string;
- try {
- raw = await readFile(absolutePath, "utf8");
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return `Error: File "${filePath}" not found.`;
- }
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- // A truly empty file (0 bytes) would otherwise slip through the
- // line-counting below: `"".split("\n")` is `[""]` and there's no
- // trailing newline, yielding a spurious `totalLines === 1`.
- if (raw === "") {
- return `(empty file: ${filePath})`;
- }
-
- const allLines = raw.split("\n");
- // `split("\n")` produces an extra empty entry when the file ends with a
- // newline. The total line count we report to the caller should match
- // the human-visible line count (lines that have content or terminate
- // with \n).
- const trailingNewline = raw.endsWith("\n");
- const totalLines = trailingNewline ? allLines.length - 1 : allLines.length;
-
- if (totalLines === 0) {
- return `(empty file: ${filePath})`;
- }
-
- if (offset > totalLines) {
- return `Error: offset ${offset} exceeds file length (${totalLines} lines).`;
- }
-
- const startIdx = offset - 1; // 0-indexed
- const endIdx = Math.min(startIdx + limit, totalLines);
- const slice = allLines.slice(startIdx, endIdx);
-
- // Apply per-line truncation. We tag truncated lines with the line
- // number and total chars so the AI knows how to call read_file_slice.
- const rendered: string[] = [];
- for (let i = 0; i < slice.length; i++) {
- const lineNumber = startIdx + i + 1;
- const line = slice[i] ?? "";
- if (line.length > MAX_LINE_CHARS) {
- const visible = line.slice(0, MAX_LINE_CHARS);
- rendered.push(
- `${visible}...[line ${lineNumber} truncated, total ${line.length.toLocaleString()} chars; use read_file_slice with path="${filePath}" line=${lineNumber} to read more]`,
- );
- } else {
- rendered.push(line);
- }
- }
-
- const header = `[file: ${filePath} — lines ${offset}-${endIdx} of ${totalLines}]`;
- return `${header}\n${rendered.join("\n")}`;
- },
- };
-}
diff --git a/packages/core/src/tools/read-tab.ts b/packages/core/src/tools/read-tab.ts
deleted file mode 100644
index e80dbd0..0000000
--- a/packages/core/src/tools/read-tab.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { z } from "zod";
-import type { AgentStatus, ToolDefinition } from "../types/index.js";
-import type { TabResolution } from "./send-to-tab.js";
-
-export interface ReadTabCallbacks {
- /** Resolve a (possibly short) handle to one open tab. */
- resolveShortId(prefix: string): TabResolution;
- /**
- * Return the target tab's most recent COMPLETED assistant turn as plain
- * text, plus its current status. `text` is null when the tab has no
- * completed assistant turn yet.
- */
- getLastResponse(tabId: string): { text: string | null; status: AgentStatus };
- /** Snapshot of currently-open tabs, for "available tabs" error hints. */
- listOpenHandles(): Array<{ handle: string; title: string }>;
-}
-
-/** Render the "available tabs" hint shared by the none/ambiguous branches. */
-function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
- if (handles.length === 0) return "No other tabs are currently open.";
- const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
- return ["Currently open tabs:", ...lines].join("\n");
-}
-
-export function createReadTabTool(callbacks: ReadTabCallbacks): ToolDefinition {
- return {
- name: "read_tab",
- description: [
- "Read the most recent completed response from another tab (agent) by its short ID.",
- "",
- "Returns a SNAPSHOT — it does NOT block or wait for the target to finish.",
- " - If the target is idle, you get its just-finished turn.",
- " - If the target is still running, you get its PREVIOUS completed turn (if any);",
- " call read_tab again later to get the newest one.",
- "",
- "Use this after send_to_tab to collect another agent's reply. IDs are git-style",
- "prefixes: pass any length that uniquely identifies the target (min 4 chars).",
- ].join("\n"),
- parameters: z.object({
- tab_id: z
- .string()
- .describe(
- "The short ID (handle) of the tab to read, as shown in the tab bar. Any unique-length prefix works (min 4 chars).",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
-
- if (!rawId) {
- return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
- }
-
- const resolution = callbacks.resolveShortId(rawId);
-
- if (resolution.status === "none") {
- return [
- `Error: no open tab matches the ID "${rawId}".`,
- "",
- renderOpenHandles(callbacks.listOpenHandles()),
- ].join("\n");
- }
- if (resolution.status === "ambiguous") {
- const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
- return [
- `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
- matches,
- "",
- "Add one or more characters to disambiguate.",
- ].join("\n");
- }
-
- const target = resolution.tab;
- const { text, status } = callbacks.getLastResponse(target.id);
-
- const runningNote =
- status === "running"
- ? " (this tab is still running; the response below is its previous completed turn — read again later for the newest)"
- : "";
-
- if (text === null) {
- const reason =
- status === "running"
- ? "it is still working on its first turn"
- : "it has no assistant responses yet";
- return `Tab ${target.handle} (${target.title}) has no completed response — ${reason}.`;
- }
-
- return [
- `<tab_response tab="${target.handle}" status="${status}"${runningNote}>`,
- text,
- "</tab_response>",
- ].join("\n");
- },
- };
-}
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
deleted file mode 100644
index ff6f4d1..0000000
--- a/packages/core/src/tools/registry.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { Tool } from "ai";
-import { jsonSchema, tool } from "ai";
-import { zodToJsonSchema } from "zod-to-json-schema";
-import type { ToolDefinition } from "../types/index.js";
-
-/**
- * Strip JSON Schema fields that Anthropic's API does not accept from a
- * `zodToJsonSchema()` output. The Anthropic `/messages` API rejects (or
- * silently ignores) tools whose `input_schema` contains `$schema`,
- * `additionalProperties`, `default`, or `nullable` — when this happens
- * Claude never sees the tool and the model "thinks forever" instead of
- * calling it.
- *
- * The stripped fields are also harmless to remove for OpenAI-compatible
- * endpoints, so we apply this unconditionally.
- */
-function normalizeForAnthropic(schema: Record<string, unknown>): Record<string, unknown> {
- delete schema.$schema;
- delete schema.additionalProperties;
- delete schema.default;
- delete schema.nullable;
-
- const properties = schema.properties;
- if (properties && typeof properties === "object") {
- for (const key of Object.keys(properties as Record<string, unknown>)) {
- const prop = (properties as Record<string, unknown>)[key];
- if (prop && typeof prop === "object") {
- normalizeForAnthropic(prop as Record<string, unknown>);
- }
- }
- }
-
- const items = schema.items;
- if (items && typeof items === "object") {
- normalizeForAnthropic(items as Record<string, unknown>);
- }
-
- return schema;
-}
-
-/**
- * Convert an internal `ToolDefinition` (Zod-parameterised) to an AI SDK v6
- * `Tool` object.
- *
- * Critically, NO `execute` function is attached. The agent's manual tool
- * loop (see agent.ts) handles execution itself — for permission prompts,
- * shell-output streaming, and queued-message injection. Without `execute`,
- * the SDK never auto-runs tools; it only surfaces `tool-call` events from
- * `fullStream` that agent.ts collects and dispatches.
- */
-function toAISDKTool(def: ToolDefinition): Tool {
- const raw = zodToJsonSchema(def.parameters) as Record<string, unknown>;
- const normalized = normalizeForAnthropic(raw);
- return tool({
- description: def.description,
- inputSchema: jsonSchema(normalized),
- });
-}
-
-export function createToolRegistry(tools: ToolDefinition[]) {
- const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
-
- return {
- getTools(): ToolDefinition[] {
- return [...toolMap.values()];
- },
-
- getTool(name: string): ToolDefinition | undefined {
- return toolMap.get(name);
- },
-
- /**
- * Returns AI SDK v6 `Tool` objects keyed by tool name, for passing
- * directly to `streamText({ tools })`.
- *
- * Each tool has:
- * - `description` — forwarded verbatim from the internal definition.
- * - `inputSchema` — Zod schema converted to JSONSchema7 via
- * `zod-to-json-schema`, then wrapped with the v6
- * `jsonSchema()` helper.
- * - NO `execute` — intentional; see `toAISDKTool` above.
- */
- getAISDKTools(): Record<string, Tool> {
- const result: Record<string, Tool> = {};
- for (const [name, def] of toolMap) {
- result[name] = toAISDKTool(def);
- }
- return result;
- },
- };
-}
diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts
deleted file mode 100644
index 80c3715..0000000
--- a/packages/core/src/tools/retrieve.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-export interface RetrieveCallbacks {
- getResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
-}
-
-export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition {
- return {
- name: "retrieve",
- description: [
- "Wait for a child agent or backgrounded shell command to finish and retrieve its result. This tool BLOCKS until completion.",
- "",
- "Pass the ID returned by summon (agent_id) or by an interrupted run_shell (job_id). Once it finishes, the output is returned.",
- "If an error occurred, the error message is returned instead.",
- "",
- "Typical usage:",
- ' 1. summon({ task: "...", tools: [...] }) -> get agent_id',
- " 2. ... do other work or summon more agents ...",
- ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result',
- "",
- "Also used for backgrounded shell commands:",
- " If run_shell is interrupted by a user message, it returns a job_id (run_shell_...).",
- ' Use retrieve({ agent_id: "run_shell_..." }) to get the final output when ready.',
- ].join("\n"),
- parameters: z.object({
- agent_id: z.string().describe("The agent_id returned by a previous summon call."),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const agentId = args.agent_id as string;
- const queueCallbacks = context?.queueCallbacks;
-
- try {
- let outcome: { status: "done"; result: string } | { status: "error"; error: string };
-
- if (queueCallbacks) {
- const childPromise = callbacks.getResult(agentId);
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([childPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
- return `The subagent (agent_id: ${agentId}) has not completed its task yet. You will need to call retrieve with this agent_id again later to get the result.\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`;
- }
-
- // Child finished first — clean up the queue listener
- cancelQueueWait();
- outcome = raceResult;
- } else {
- outcome = await callbacks.getResult(agentId);
- }
-
- if (outcome.status === "done") {
- return ["<agent_result>", outcome.result, "</agent_result>"].join("\n");
- }
- return `Agent error: ${outcome.error}`;
- } catch (err) {
- return `Error retrieving result: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts
deleted file mode 100644
index ec2db9c..0000000
--- a/packages/core/src/tools/run-shell.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { spawn } from "node:child_process";
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes
-
-export interface BackgroundShellJob {
- command: string;
- stdout: string;
- stderr: string;
- /** Resolves when the process exits */
- completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>;
-}
-
-/** Shared store for shell commands that were backgrounded due to user interrupt */
-export class BackgroundShellStore {
- private jobs = new Map<string, BackgroundShellJob>();
-
- register(job: BackgroundShellJob): string {
- const id = `run_shell_${randomUUID()}`;
- this.jobs.set(id, job);
- // Auto-cleanup after completion + 10 minutes
- job.completion.finally(() => {
- setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000);
- });
- return id;
- }
-
- async getResult(
- id: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const job = this.jobs.get(id);
- if (!job) {
- return { status: "error", error: `No background shell job found with id '${id}'` };
- }
- const result = await job.completion;
- return { status: "done", result: JSON.stringify(result) };
- }
-
- has(id: string): boolean {
- return this.jobs.has(id);
- }
-}
-
-export function createRunShellTool(
- workingDirectory: string,
- shellStore?: BackgroundShellStore,
-): ToolDefinition {
- return {
- name: "run_shell",
- description:
- "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.",
- parameters: z.object({
- command: z.string().describe("The shell command to execute"),
- timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.",
- ),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const command = args.command as string;
- const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT;
- const background = (args.background as boolean | undefined) ?? false;
-
- const [shell, shellArgs] = getShell();
- const child = spawn(shell, [...shellArgs, command], {
- cwd: workingDirectory,
- env: process.env,
- timeout,
- stdio: ["ignore", "pipe", "pipe"],
- });
-
- let stdout = "";
- let stderr = "";
-
- const completionPromise = new Promise<{
- stdout: string;
- stderr: string;
- exitCode: number;
- error?: string;
- }>((resolve) => {
- child.stdout?.on("data", (data: Buffer) => {
- const chunk = data.toString();
- stdout += chunk;
- context?.onOutput?.(chunk, "stdout");
- });
- child.stderr?.on("data", (data: Buffer) => {
- const chunk = data.toString();
- stderr += chunk;
- context?.onOutput?.(chunk, "stderr");
- });
-
- child.on("close", (exitCode) => {
- resolve({ stdout, stderr, exitCode: exitCode ?? 1 });
- });
-
- child.on("error", (err) => {
- resolve({ stdout, stderr, exitCode: 1, error: err.message });
- });
- });
-
- // If background mode requested, register immediately and return job ID
- if (background && shellStore) {
- const jobId = shellStore.register({
- command,
- stdout,
- stderr,
- completion: completionPromise,
- });
- return [
- `Command started in background.`,
- `job_id: ${jobId}`,
- `command: ${command}`,
- ``,
- `Use the retrieve tool with this job_id to get the result when ready.`,
- ].join("\n");
- }
-
- const queueCallbacks = context?.queueCallbacks;
-
- if (queueCallbacks && shellStore) {
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([completionPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- // Background the still-running process
- const jobId = shellStore.register({
- command,
- stdout,
- stderr,
- completion: completionPromise,
- });
-
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
-
- return [
- `Command backgrounded — still running.`,
- `job_id: ${jobId}`,
- `command: ${command}`,
- `stdout so far: ${stdout.slice(-500) || "(none)"}`,
- `stderr so far: ${stderr.slice(-500) || "(none)"}`,
- ``,
- `Use the retrieve tool with this job_id to get the final result when ready.`,
- ``,
- `[USER INTERRUPT]`,
- `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`,
- ``,
- userMessages,
- ].join("\n");
- }
-
- // Command finished before interrupt — clean up queue listener
- cancelQueueWait();
- return JSON.stringify(raceResult);
- }
-
- const result = await completionPromise;
- return JSON.stringify(result);
- },
- };
-}
-
-function getShell(): [string, string[]] {
- return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]];
-}
diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts
deleted file mode 100644
index 4350f6a..0000000
--- a/packages/core/src/tools/search-code.ts
+++ /dev/null
@@ -1,362 +0,0 @@
-import { spawn } from "node:child_process";
-import { stat } from "node:fs/promises";
-import { relative, sep } from "node:path";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-// Resolve the `cs` binary: an explicit override wins, otherwise rely on PATH.
-// The deployed images build a patched, statically-linked `cs` into
-// /usr/local/bin/cs (see Dockerfile); local dev can point DISPATCH_CS_BIN at a
-// custom build. Read at call time so the environment can change at runtime
-// (and so tests can point it at a stub or temp build).
-function resolveCsBin(): string {
- return process.env.DISPATCH_CS_BIN || "cs";
-}
-
-const DEFAULT_RESULT_LIMIT = 20;
-const MAX_RESULT_LIMIT = 100;
-const MAX_CONTEXT = 20;
-const MIN_SNIPPET_LENGTH = 50;
-const MAX_SNIPPET_LENGTH = 2000;
-const TIMEOUT_MS = 30_000;
-// Hard cap on any single rendered snippet line. Mirrors read-file.ts so a
-// matched minified/generated line (e.g. a 2 MB bundle line) can't blow up the
-// payload. The universal truncator bounds total output; this bounds per-line.
-const MAX_LINE_CHARS = 500;
-
-/** Maps the `only` enum to the corresponding cs flag. */
-const ONLY_FLAGS: Record<string, string> = {
- code: "--only-code",
- comments: "--only-comments",
- strings: "--only-strings",
- declarations: "--only-declarations",
- usages: "--only-usages",
-};
-
-/** One line within a cs JSON match result. */
-interface CsLine {
- line_number: number;
- content: string;
- match_positions?: Array<[number, number]>;
-}
-
-/** A single file result in cs `-f json` output. */
-interface CsResult {
- filename: string;
- location: string;
- score: number;
- /** Present in "lines"/"grep" snippet modes. */
- lines?: CsLine[];
- /**
- * Present instead of `lines` in cs's "snippet" mode (the default "auto"
- * mode selects it for prose). We force a lines-based mode (see buildFlags),
- * but this is kept as a defensive fallback so a content-shape result is
- * still rendered rather than shown as a bare header.
- */
- content?: string;
- matchlocations?: Array<[number, number]>;
- language?: string;
- total_lines?: number;
-}
-
-export function createSearchCodeTool(workingDirectory: string): ToolDefinition {
- return {
- name: "search_code",
- description:
- "Search the codebase by query using `cs` (code spelunker) — a fast, relevance-ranked code search engine. " +
- "Prefer this over grep/find for EXPLORATORY 'where is X / how does Y work' searches: it ranks the most " +
- "relevant files first and returns matching snippets with line numbers, so you spend fewer turns and tokens. " +
- "It respects .gitignore and skips hidden/binary files. " +
- 'Query syntax: space-separated terms are AND\'d; supports OR, NOT, "exact phrases", fuzzy~1, /regex/, and ' +
- "metadata filters like lang:Go, file:test, path:src. " +
- "It is a ranked text search, NOT a semantic/LSP index: it won't resolve types or imports. For an EXHAUSTIVE " +
- "list of every exact match (e.g. before a rename), use run_shell with ripgrep (rg) instead.",
- parameters: z.object({
- query: z
- .string()
- .describe(
- 'The search query. Terms are AND\'d by default. Supports OR, NOT, "phrases", fuzzy~1, /regex/, and filters like lang:Go, file:test, path:src.',
- ),
- path: z
- .string()
- .optional()
- .describe(
- "Subdirectory to scope the search to, relative to the working directory. Defaults to the whole working directory.",
- ),
- case_sensitive: z
- .boolean()
- .optional()
- .describe("Make the search case-sensitive. Default: false (case-insensitive)."),
- include_ext: z
- .string()
- .optional()
- .describe(
- 'Comma-separated list of file extensions to limit the search to (case-sensitive), e.g. "go,ts,lua".',
- ),
- exclude_pattern: z
- .string()
- .optional()
- .describe(
- 'Comma-separated list of path patterns to exclude (case-sensitive), e.g. "vendor,_test.go".',
- ),
- context: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe(
- `Lines of context to show before and after each matching line (0-${MAX_CONTEXT}). When set, switches to a grep-style per-line window.`,
- ),
- result_limit: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Maximum number of file results to return. Default: ${DEFAULT_RESULT_LIMIT}, max: ${MAX_RESULT_LIMIT}.`,
- ),
- snippet_length: z
- .number()
- .int()
- .min(MIN_SNIPPET_LENGTH)
- .optional()
- .describe(
- `Snippet size in bytes for prose/text files (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Has little effect on code files, which use a fixed line window — use 'context' to widen code snippets.`,
- ),
- only: z
- .enum(["code", "comments", "strings", "declarations", "usages"])
- .optional()
- .describe(
- "Restrict matches structurally: code, comments, strings, declarations (definitions like func/class/type), " +
- "or usages (call sites). Best-effort and language-dependent — strong for Go/TypeScript/Python/Lua/Luau, " +
- "unavailable for unsupported languages (which fall back to plain text ranking).",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const query = typeof args.query === "string" ? args.query : "";
- if (query.trim() === "") {
- return "Error: query is required (a non-empty string).";
- }
-
- // Resolve and contain the optional search path within the workdir.
- // Canonicalize so a symlink-in-workdir pointing outside is detected,
- // matching the containment semantics of list_files / read_file.
- const relPath = asString(args.path) ?? ".";
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const searchDir = await canonicalize(workingDirectory, relPath);
- if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${relPath}" is outside the working directory.`;
- }
-
- // cs's --dir expects a directory; pointing it at a file silently
- // returns no matches. Catch that and give an actionable hint instead
- // of a misleading "No matches found".
- if (relPath !== ".") {
- try {
- const st = await stat(searchDir);
- if (!st.isDirectory()) {
- return `Error: Path "${relPath}" is a file, not a directory. The 'path' parameter scopes the search to a directory; use read_file to read a single file.`;
- }
- } catch {
- return `Error: Path "${relPath}" does not exist in the working directory.`;
- }
- }
-
- const flags = buildFlags(args, searchDir);
- // `--` terminates cs flag parsing so a query that begins with "-"
- // (e.g. "-hello" or "--foo") is treated as the positional search term
- // rather than parsed as a (possibly invalid) cs flag.
- const spawnArgs = [...flags, "--", query];
-
- let stdout = "";
- let stderr = "";
- const result = await new Promise<{
- code: number | null;
- signal: NodeJS.Signals | null;
- error?: string;
- errorCode?: string;
- }>((resolve) => {
- const child = spawn(resolveCsBin(), spawnArgs, {
- cwd: workingDirectory,
- env: process.env,
- timeout: TIMEOUT_MS,
- stdio: ["ignore", "pipe", "pipe"],
- });
- child.stdout?.on("data", (d: Buffer) => {
- stdout += d.toString();
- });
- child.stderr?.on("data", (d: Buffer) => {
- stderr += d.toString();
- });
- child.on("close", (code, signal) => resolve({ code, signal }));
- child.on("error", (err) =>
- resolve({
- code: null,
- signal: null,
- error: err.message,
- errorCode: (err as NodeJS.ErrnoException).code,
- }),
- );
- });
-
- if (result.error) {
- // The binary is missing or not executable — give an actionable hint.
- if (result.errorCode === "ENOENT" || result.error.includes("ENOENT")) {
- return missingBinaryError();
- }
- return `Error: failed to run cs: ${result.error}`;
- }
-
- // A signal kill (e.g. SIGTERM from the spawn timeout) or a non-zero
- // exit means cs failed — surface it (with stderr) instead of silently
- // reporting "No matches found". cs exits 0 even when there are no
- // matches, so a clean exit always falls through to the parsing below.
- if (result.signal) {
- const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
- if (result.signal === "SIGTERM") {
- return `Error: cs search timed out after ${TIMEOUT_MS / 1000}s. Try a narrower query or a smaller path.${detail}`;
- }
- return `Error: cs was terminated by signal ${result.signal}.${detail}`;
- }
- if (result.code !== 0) {
- const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
- return `Error: cs exited with code ${result.code}.${detail}`;
- }
-
- // cs prints `null` (and exit 0) when there are no matches.
- const trimmed = stdout.trim();
- if (trimmed === "" || trimmed === "null") {
- return "No matches found.";
- }
-
- let parsed: CsResult[];
- try {
- parsed = JSON.parse(trimmed) as CsResult[];
- } catch {
- // Couldn't parse — surface what cs produced so the caller isn't blind.
- const detail = stderr.trim() ? `\nstderr: ${stderr.trim()}` : "";
- return `Error: could not parse cs output as JSON.${detail}\n\nRaw output:\n${trimmed.slice(0, 2000)}`;
- }
-
- if (!Array.isArray(parsed) || parsed.length === 0) {
- return "No matches found.";
- }
-
- return formatResults(parsed, absoluteWorkDir);
- },
- };
-}
-
-/** Build the cs CLI flags (everything except the trailing query). */
-function buildFlags(args: Record<string, unknown>, searchDir: string): string[] {
- const flags: string[] = ["-f", "json", "--dir", searchDir];
-
- if (args.case_sensitive === true) flags.push("-c");
-
- const includeExt = asString(args.include_ext);
- if (includeExt) flags.push("-i", includeExt);
-
- const excludePattern = asString(args.exclude_pattern);
- if (excludePattern) flags.push("-x", excludePattern);
-
- // Snippet mode selection. cs's default ("auto") emits a `lines[]` array for
- // code but a single `content` string for prose (.md/.html/…), which our
- // renderer can't show — so prose results would come back as bare headers.
- // It also ignores -C/--context entirely in auto/lines mode.
- //
- // - No `context` given → force "lines": every file type (code AND prose)
- // returns a `lines[]` window, so prose snippets render too.
- // - `context` given → use "grep": the only mode where -C actually widens
- // the window; it likewise returns `lines[]` for all file types.
- if (typeof args.context === "number") {
- const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT);
- flags.push("--snippet-mode", "grep", "-C", String(context));
- } else {
- flags.push("--snippet-mode", "lines");
- }
-
- const requestedLimit =
- typeof args.result_limit === "number"
- ? clamp(Math.floor(args.result_limit), 1, MAX_RESULT_LIMIT)
- : DEFAULT_RESULT_LIMIT;
- flags.push("--result-limit", String(requestedLimit));
-
- if (typeof args.snippet_length === "number") {
- const snippet = clamp(Math.floor(args.snippet_length), MIN_SNIPPET_LENGTH, MAX_SNIPPET_LENGTH);
- flags.push("-n", String(snippet));
- }
-
- const only = asString(args.only);
- if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]);
-
- return flags;
-}
-
-/** Render cs JSON results into compact, readable per-file blocks. */
-function formatResults(results: CsResult[], absoluteWorkDir: string): string {
- const blocks: string[] = [];
- // Match the workdir only at a path boundary so a sibling dir that merely
- // shares the prefix (e.g. workdir "/app" vs "/app-secrets") isn't treated
- // as nested and rendered as a "../app-secrets/..." relative path.
- const workdirPrefix = absoluteWorkDir.endsWith(sep) ? absoluteWorkDir : absoluteWorkDir + sep;
- for (const r of results) {
- // Present paths relative to the workdir so output is portable and compact.
- const insideWorkdir = r.location === absoluteWorkDir || r.location.startsWith(workdirPrefix);
- const rel = insideWorkdir ? relative(absoluteWorkDir, r.location) || r.filename : r.location;
- const lang = r.language ? ` [${r.language}]` : "";
- const score = typeof r.score === "number" ? ` (score ${r.score.toFixed(2)})` : "";
- const header = `${rel}${lang}${score}`;
-
- let body: string[];
- if (r.lines && r.lines.length > 0) {
- body = r.lines.map((l) => {
- const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " ";
- return ` ${marker} ${l.line_number}: ${truncateLine(l.content)}`;
- });
- } else if (r.content && r.content.trim() !== "") {
- // Fallback for cs's "snippet"-mode shape (no per-line numbers): show
- // the snippet text itself so the result isn't a bare header.
- body = r.content.split("\n").map((line) => ` ${truncateLine(line)}`);
- } else {
- body = [" (match in file; no snippet available)"];
- }
-
- blocks.push([header, ...body].join("\n"));
- }
-
- const count = results.length;
- const heading = `Found matches in ${count} file${count === 1 ? "" : "s"} (ranked by relevance):`;
- return [heading, "", blocks.join("\n\n")].join("\n");
-}
-
-function clamp(n: number, min: number, max: number): number {
- return Math.min(max, Math.max(min, n));
-}
-
-/** Cap an individual snippet line so a minified/generated line can't bloat output. */
-function truncateLine(line: string): string {
- if (line.length <= MAX_LINE_CHARS) return line;
- return `${line.slice(0, MAX_LINE_CHARS)}… [line truncated, ${line.length.toLocaleString()} chars]`;
-}
-
-/**
- * Coerce a tool argument to a trimmed string, or undefined. Guards against a
- * model hallucinating a non-string (e.g. an array `["ts","go"]`) for a
- * string-typed param: returning undefined makes the flag a no-op instead of
- * throwing `x.trim is not a function` and crashing the tool call.
- */
-function asString(v: unknown): string | undefined {
- if (typeof v !== "string") return undefined;
- const t = v.trim();
- return t === "" ? undefined : t;
-}
-
-function missingBinaryError(): string {
- return [
- "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.",
- "Install it with: go install github.com/boyter/cs/[email protected]",
- "or set the DISPATCH_CS_BIN environment variable to the path of a cs binary.",
- "(In the official Docker images cs is bundled at /usr/local/bin/cs.)",
- ].join("\n");
-}
diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts
deleted file mode 100644
index eae6bfa..0000000
--- a/packages/core/src/tools/send-to-tab.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-
-/**
- * A tab reference surfaced to the `send_to_tab` / `read_tab` tools. The tools
- * are intentionally decoupled from the DB `TabRow` shape — the AgentManager
- * maps `resolveTabPrefix(...)` results down to this minimal projection so the
- * tools (and their unit tests) never depend on the persistence layer.
- */
-export interface ResolvedTabRef {
- /** The tab's canonical full UUID. */
- id: string;
- /** The tab's display title (for disambiguation hints). */
- title: string;
- /** The tab's current short handle (shortest unique prefix). */
- handle: string;
-}
-
-/**
- * Outcome of resolving a short tab handle. Mirrors core's
- * `ResolveTabPrefixResult` but over the minimal `ResolvedTabRef` projection.
- */
-export type TabResolution =
- | { status: "ok"; tab: ResolvedTabRef }
- | { status: "none" }
- | { status: "ambiguous"; matches: ResolvedTabRef[] };
-
-export interface SendToTabCallbacks {
- /** Resolve a (possibly short) handle to one open tab. */
- resolveShortId(prefix: string): TabResolution;
- /**
- * Deliver `message` to `tabId`. If the target is mid-turn the message is
- * queued (same path as a user message); if idle/errored it wakes the tab
- * and starts a new turn. Returns quickly — does NOT block on the turn.
- */
- deliver(
- tabId: string,
- message: string,
- ):
- | Promise<{ status: "queued" | "started" | "suppressed" }>
- | { status: "queued" | "started" | "suppressed" };
- /** Snapshot of currently-open tabs, for "available tabs" error hints. */
- listOpenHandles(): Array<{ handle: string; title: string }>;
- /** The calling tab's own id + handle — used to block self-sends and to
- * stamp provenance onto the delivered message. */
- self: { id: string; handle: string };
- /**
- * Whether THIS calling tab also has the `read_tab` tool granted. The
- * tab-messaging permissions are split, so a tab can hold `send_to_tab`
- * without `read_tab`. When false, the tool must NOT tell the agent to use
- * `read_tab` (it doesn't have it) — replies only arrive on their own.
- */
- canReadTab: boolean;
-}
-
-/** Render the "available tabs" hint shared by the none/ambiguous branches. */
-function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
- if (handles.length === 0) return "No other tabs are currently open.";
- const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
- return ["Currently open tabs:", ...lines].join("\n");
-}
-
-export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition {
- // The `read_tab` follow-up hint is only truthful when this tab actually
- // holds the `read_tab` tool (the permissions are split). When it doesn't,
- // the only honest guidance is that a reply will wake it as a new message — never tell
- // the agent to call a tool it wasn't granted.
- const waitLine = callbacks.canReadTab
- ? "money. If the target replies it will WAKE you with a new message in a later turn; you"
- : "money. If the target replies it will WAKE you with a new message in a later turn.";
- const readTabLine = callbacks.canReadTab
- ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"]
- : [];
- const keepGoingLine = callbacks.canReadTab
- ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now."
- : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now.";
- return {
- name: "send_to_tab",
- description: [
- "Send a message to another tab (agent) by its short ID — the handle shown in the tab bar.",
- "",
- "Behaviour mirrors a user sending a message:",
- " - If the target tab is mid-turn (busy), your message is QUEUED and picked up next.",
- " - If the target tab is idle, your message WAKES it and starts a new turn.",
- "",
- "This is fire-and-forget: it returns immediately and does NOT wait for a reply.",
- "Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and",
- waitLine,
- ...readTabLine,
- keepGoingLine,
- "",
- "Your tab ID is auto-added to the top of the message so the recipient knows who to reply",
- "to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;",
- "a plain text response reaches only their own user, not you.",
- "IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).",
- "If the ID is ambiguous you'll be asked to add a character.",
- ].join("\n"),
- parameters: z.object({
- tab_id: z
- .string()
- .describe(
- "The short ID (handle) of the target tab, as shown in the tab bar. Any unique-length prefix of the tab's id works (min 4 chars).",
- ),
- message: z
- .string()
- .describe("The message to deliver to the target tab, exactly as a user would type it."),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
- const message = (args.message as string | undefined) ?? "";
-
- if (!rawId) {
- return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
- }
- if (!message.trim()) {
- return "Error: message must not be empty.";
- }
-
- const resolution = callbacks.resolveShortId(rawId);
-
- if (resolution.status === "none") {
- return [
- `Error: no open tab matches the ID "${rawId}".`,
- "",
- renderOpenHandles(callbacks.listOpenHandles()),
- ].join("\n");
- }
- if (resolution.status === "ambiguous") {
- const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
- return [
- `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
- matches,
- "",
- "Add one or more characters to disambiguate.",
- ].join("\n");
- }
-
- const target = resolution.tab;
-
- if (target.id === callbacks.self.id) {
- return "Error: cannot send a message to your own tab.";
- }
-
- // Stamp provenance so the recipient (and the watching user) can see
- // which tab the message came from and how to reply. The header makes
- // clear this is a PEER AGENT, not the recipient's own user, and the
- // footer states the reply contract: a reply (only if warranted) must
- // go back through `send_to_tab`, since a plain text answer reaches
- // only the recipient's own user — not this sender.
- const delivered = [
- `[message from tab ${callbacks.self.handle} — this is another agent, NOT your user]`,
- "",
- message,
- "",
- `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`,
- ].join("\n");
-
- try {
- const result = await callbacks.deliver(target.id, delivered);
- if (result.status === "suppressed") {
- // The target hit its automatic agent-to-agent wake limit. The
- // message was preserved (queued) but did NOT start a turn — a
- // human must step in. Tell the sender plainly so it stops
- // hammering the target and creating a runaway loop.
- return [
- `Message HELD for tab ${target.handle} (${target.title}) — it was NOT delivered as a wake.`,
- `That tab has reached its automatic agent-to-agent message limit, so it will not`,
- `auto-respond again until a human sends it a message. Do not keep resending:`,
- `your message is already queued and will be seen when a human resumes that tab.`,
- ].join("\n");
- }
- const verb =
- result.status === "queued"
- ? "queued (target is busy; it will be picked up next turn)"
- : "delivered (target was idle; a new turn has started)";
- const tail = callbacks.canReadTab
- ? [
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- `will WAKE you with a new message later; you can also call read_tab with "${target.handle}"`,
- "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY",
- "waiting for this reply, end your turn now.",
- ]
- : [
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- "will WAKE you with a new message later. Keep working if you have other tasks; if",
- "you are ONLY waiting for this reply, end your turn now.",
- ];
- return [
- `Message ${verb}. Target tab: ${target.handle} (${target.title}).`,
- "",
- ...tail,
- ].join("\n");
- } catch (err) {
- return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts
deleted file mode 100644
index b70108b..0000000
--- a/packages/core/src/tools/shell-analyze.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { createRequire } from "node:module";
-import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
-import * as BashArity from "./bash-arity.js";
-
-// Commands that touch files — triggers external_directory check.
-// Includes any command that takes file paths as arguments and could leak
-// information about external directories.
-//
-// Known gaps (not currently checked):
-// - Redirections: `echo x > /etc/file` — the redirect target is not inspected
-// - `cd` state changes: we don't track cwd mutations across pipeline stages
-// - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely
-const FILE_COMMANDS = new Set([
- "rm",
- "cp",
- "mv",
- "mkdir",
- "touch",
- "chmod",
- "chown",
- "cat",
- "ls",
- "find",
- "grep",
- "head",
- "tail",
- "less",
- "more",
- "wc",
- "diff",
- "file",
- "stat",
- "du",
- "df",
-]);
-
-// Lazy-initialized parser
-let parserPromise: Promise<Parsers> | null = null;
-
-interface Parsers {
- bash: import("web-tree-sitter").Parser;
-}
-
-async function getParser(): Promise<Parsers> {
- if (parserPromise) return parserPromise;
- parserPromise = initParser();
- return parserPromise;
-}
-
-async function initParser(): Promise<Parsers> {
- const { Parser, Language } = await import("web-tree-sitter");
-
- // Load the main WASM binary from node_modules
- const require = createRequire(import.meta.url);
- const webTreeSitterPath = require.resolve("web-tree-sitter/web-tree-sitter.wasm");
- const wasmBinary = await readFile(webTreeSitterPath);
- await Parser.init({ wasmBinary });
-
- const bashWasmPath = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
- const bashLang = await Language.load(bashWasmPath);
-
- const bash = new Parser();
- bash.setLanguage(bashLang);
-
- return { bash };
-}
-
-// Analyze a shell command and return permission patterns
-export async function analyzeCommand(
- command: string,
- workingDirectory: string,
-): Promise<{ dirs: string[]; patterns: string[]; always: string[] }> {
- try {
- const parsers = await getParser();
- const tree = parsers.bash.parse(command);
- if (!tree) return { dirs: [], patterns: [command], always: [] };
-
- return collect(tree.rootNode, command, workingDirectory);
- } catch {
- // Parse failure — return basic patterns
- return { dirs: [], patterns: [command], always: [] };
- }
-}
-
-function collect(
- node: import("web-tree-sitter").Node,
- _source: string,
- wd: string,
-): { dirs: string[]; patterns: string[]; always: string[] } {
- const dirs: string[] = [];
- const patterns: string[] = [];
- const always: string[] = [];
-
- // Walk all command nodes
- const commands = node.descendantsOfType("command");
-
- for (const cmd of commands) {
- const parts = extractParts(cmd);
- const name = parts[0]?.toLowerCase();
- if (!name) continue;
-
- // Get the command source text
- const cmdText = cmd.text;
- patterns.push(cmdText);
-
- // Normalize to always pattern
- always.push(`${BashArity.prefix(parts).join(" ")} *`);
-
- // Check if this is a file-touching command
- if (FILE_COMMANDS.has(name)) {
- // Extract path arguments (skip flags starting with -)
- const pathArgs = parts.slice(1).filter((a) => !a.startsWith("-"));
- for (const arg of pathArgs) {
- const resolved = resolvePath(arg, wd);
- if (resolved && !isInsideWorkspace(resolved, wd)) {
- const parent = dirname(resolved);
- dirs.push(parent);
- }
- }
- }
- }
-
- return {
- dirs: [...new Set(dirs)],
- patterns: [...new Set(patterns)],
- always: [...new Set(always)],
- };
-}
-
-// Helper to extract command parts from a command AST node
-function extractParts(cmd: import("web-tree-sitter").Node): string[] {
- const parts: string[] = [];
- for (const child of cmd.children) {
- if (
- child.type === "command_name" ||
- child.type === "word" ||
- child.type === "string" ||
- child.type === "raw_string"
- ) {
- const text = child.text.replace(/^['"]|['"]$/g, "");
- if (text) parts.push(text);
- }
- }
- return parts;
-}
-
-function resolvePath(arg: string, wd: string): string | null {
- try {
- if (isAbsolute(arg)) return arg;
- return resolve(wd, arg);
- } catch {
- return null;
- }
-}
-
-function isInsideWorkspace(filePath: string, wd: string): boolean {
- const normalizedWd = resolve(wd);
- const rel = relative(normalizedWd, filePath);
- // rel === "" means filePath IS the workspace root — that is inside.
- // If relative path starts with "../" or is ".." exactly, or is an absolute path
- // (on Windows when drives differ), the file is outside the workspace.
- const isOutside =
- rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel);
- return !isOutside;
-}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
deleted file mode 100644
index 2a076e6..0000000
--- a/packages/core/src/tools/summon.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { z } from "zod";
-import type { AgentDefinition, ToolDefinition } from "../types/index.js";
-
-export interface SummonCallbacks {
- spawn(options: {
- task: string;
- tools: string[];
- workingDirectory?: string;
- /**
- * Optional slug of an `AgentDefinition` (loaded from
- * `~/.config/dispatch/agents/` or `<projectDir>/.dispatch/agents/`)
- * to use as the basis for the spawned child. When provided,
- * the definition's tools, models, and cwd override the
- * `tools` and `workingDirectory` parameters passed alongside.
- */
- agentSlug?: string;
- /**
- * When true, spawn the agent as an independent top-level "user
- * agent" tab (no parent, persistent, fire-and-forget) instead of
- * a subagent child tab. Only honoured when the spawning agent has
- * the `perm_user_agent` permission.
- */
- topLevel?: boolean;
- }): Promise<string>;
- getResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
-}
-
-/**
- * Summary of an agent definition surfaced to the calling LLM in the
- * summon tool's description. The shape is intentionally minimal — full
- * TOML inspection is done by reading the definition file directly,
- * which all agents are allowed to do by default.
- */
-export interface AvailableAgent {
- slug: string;
- name: string;
- description: string;
- /** Filesystem path of the TOML the agent can read for full details. */
- path: string;
-}
-
-/**
- * Render a labelled list of agents. Returns an empty array when there
- * are no agents so callers can omit the group entirely.
- */
-function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] {
- if (agents.length === 0) return [];
- const lines: string[] = [label];
- for (const a of agents) {
- const desc = a.description ? ` — ${a.description}` : "";
- lines.push(` - ${a.slug}: ${a.name}${desc}`);
- }
- return lines;
-}
-
-/**
- * Build the prose paragraph that lists available agent definitions plus
- * the disk locations where they live, injected into the summon tool's
- * description.
- *
- * `subagentEnabled` and `userAgentEnabled` independently control which
- * groups are shown — they mirror the `perm_summon` and `perm_user_agent`
- * permissions respectively:
- * - subagents only → generic "Available agents" heading;
- * - user agents only → a single user-agent group (top_level is implied);
- * - both → two labelled groups so the LLM understands which slugs
- * require `top_level=true`.
- *
- * Returns a compact "no agents defined" notice when nothing is visible.
- */
-function buildAgentsCatalog(
- subagents: AvailableAgent[],
- userAgents: AvailableAgent[],
- agentDirs: string[],
- userAgentEnabled: boolean,
- subagentEnabled: boolean,
-): string {
- const lines: string[] = [];
- lines.push("");
- lines.push("Agent definitions live on disk and can be inspected with read_file/list_files:");
- for (const d of agentDirs) {
- lines.push(` - ${d}`);
- }
-
- const visibleSubagents = subagentEnabled ? subagents : [];
- const visibleUserAgents = userAgentEnabled ? userAgents : [];
- if (visibleSubagents.length === 0 && visibleUserAgents.length === 0) {
- lines.push("");
- lines.push("No agent definitions are currently defined.");
- return lines.join("\n");
- }
-
- lines.push("");
- lines.push("To summon a specific agent, pass its slug as the 'agent' parameter.");
- lines.push("When 'agent' is set, the child inherits that definition's tools, models,");
- lines.push("and working directory; the 'tools' parameter is ignored.");
- lines.push("");
-
- // User-agent-only mode: list just the user agents. top_level is implied
- // (it is the only thing this grant can spawn), so the heading omits it.
- if (!subagentEnabled && userAgentEnabled) {
- lines.push(
- ...renderAgentGroup(
- "User agents (spawned as independent top-level tabs):",
- visibleUserAgents,
- ),
- );
- return lines.join("\n");
- }
-
- // Subagent-only mode: single generic heading.
- if (!userAgentEnabled) {
- lines.push(...renderAgentGroup("Available agents:", visibleSubagents));
- return lines.join("\n");
- }
-
- // Both enabled: two labelled groups.
- const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", visibleSubagents);
- const userAgentLines = renderAgentGroup(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- visibleUserAgents,
- );
- if (subagentLines.length > 0) {
- lines.push(...subagentLines);
- }
- if (userAgentLines.length > 0) {
- if (subagentLines.length > 0) lines.push("");
- lines.push(...userAgentLines);
- }
- return lines.join("\n");
-}
-
-/**
- * Factory for the `summon` tool. Accepts a snapshot of agent definitions
- * available at the time the tool is registered so the LLM's view of
- * which agents exist matches what `spawnChildAgent` can actually load.
- *
- * `agentDirs` is the list of filesystem paths the catalog references in
- * its description; this is information-only — the runtime resolves
- * slugs through `loadAgent` independently.
- *
- * `userAgentEnabled` mirrors the `perm_user_agent` permission and
- * `subagentEnabled` mirrors the `perm_summon` permission. They are
- * independent: the tool is registered whenever at least one is granted.
- * - subagentEnabled only → spawn ordinary subagents (no `top_level`);
- * - userAgentEnabled only → spawn ONLY top-level user agents
- * (`top_level` is forced on, the `background` knob is dropped, and
- * the catalog lists user agents only);
- * - both → full behavior (subagents plus `top_level` user agents).
- */
-export function createSummonTool(
- _defaultWorkingDirectory: string,
- callbacks: SummonCallbacks,
- availableSubagents: AvailableAgent[] = [],
- availableUserAgents: AvailableAgent[] = [],
- agentDirs: string[] = [],
- userAgentEnabled = false,
- subagentEnabled = true,
-): ToolDefinition {
- // When only the user-agent permission is granted the tool spawns user
- // agents exclusively: `top_level` is implied (and forced), subagent
- // mechanics (background, retrieve, parallel work) are irrelevant.
- const userAgentOnly = userAgentEnabled && !subagentEnabled;
-
- const catalog = buildAgentsCatalog(
- availableSubagents,
- availableUserAgents,
- agentDirs,
- userAgentEnabled,
- subagentEnabled,
- );
- const subagentSlugs = availableSubagents.map((a) => a.slug);
- const userAgentSlugs = availableUserAgents.map((a) => a.slug);
- const allSlugs = userAgentOnly
- ? userAgentSlugs
- : userAgentEnabled
- ? [...subagentSlugs, ...userAgentSlugs]
- : subagentSlugs;
-
- const toolNamesList = [
- "The 'tools' parameter controls what the child can do. Available tool names:",
- " - read_file: Read file contents",
- " - read_file_slice: Read a character-range slice of a single line",
- " - list_files: List files and directories",
- " - write_file: Write/edit files",
- " - run_shell: Execute shell commands",
- " - search_code: Search the codebase with the cs ranked code-search engine",
- " - todo: Track work items",
- " - summon: Spawn its own child agents (enables nesting)",
- " - retrieve: Collect results from its children (required if summon is given)",
- " - web_search: Search the web",
- " - youtube_transcribe: Fetch YouTube video transcripts",
- " - send_to_tab: Send a message to another tab/agent by its ID",
- " - read_tab: Read another tab/agent's latest response by its ID",
- ];
-
- const description = userAgentOnly
- ? [
- "Spawn an independent top-level user agent to work on a task.",
- "",
- "User agents are first-class top-level tabs with no parent. They are",
- "fire-and-forget: you get an agent_id back but cannot retrieve their result.",
- "The user agent runs in its own tab visible to the user.",
- "",
- ...toolNamesList,
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n")
- : [
- "Spawn a new child agent to work on a task independently.",
- "",
- "By default, blocks until the child agent finishes and returns the result directly.",
- "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.",
- "",
- "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.",
- "",
- "Pattern for parallel work:",
- " 1. Call summon multiple times with background=true to start several agents",
- " 2. Do your own work or wait",
- " 3. Call retrieve for each agent_id to collect results",
- ...(userAgentEnabled
- ? [
- "",
- "Set top_level=true to spawn an independent user agent — a first-class",
- "top-level tab with no parent. User agents are fire-and-forget: you get",
- "an agent_id back but cannot retrieve their result. top_level requires an",
- "'agent' definition listed under 'User agents' below.",
- ]
- : []),
- "",
- ...toolNamesList,
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n");
-
- const parametersShape = {
- task: z
- .string()
- .describe(
- "Detailed instructions for the child agent. Be specific about what it should do and what it should return.",
- ),
- agent: z
- .string()
- .describe(
- [
- "Slug of an agent definition to use as the basis for the child agent.",
- "Required. The child inherits the definition's tools, models, and",
- "working directory; the 'tools' parameter only narrows them further.",
- "Inspect the agent directories listed above to discover which slugs",
- "are available and what each one does.",
- allSlugs.length > 0 ? `Available slugs: ${allSlugs.join(", ")}.` : "",
- ]
- .filter(Boolean)
- .join(" "),
- ),
- // `top_level` is only an explicit choice when BOTH subagents and user
- // agents are available. In user-agent-only mode it is implied (forced
- // on), so the knob is omitted entirely.
- ...(userAgentEnabled && !userAgentOnly
- ? {
- top_level: z
- .boolean()
- .optional()
- .describe(
- [
- "If true, spawn the agent as an independent top-level user agent tab",
- "instead of a child subagent. User agents have no parent, persist on",
- "their own, and are fire-and-forget (cannot be retrieved). Requires an",
- "'agent' definition listed under 'User agents'. The 'background' option",
- "is ignored when top_level is true.",
- ].join(" "),
- ),
- }
- : {}),
- tools: z
- .array(
- z.enum([
- "read_file",
- "read_file_slice",
- "list_files",
- "write_file",
- "run_shell",
- "search_code",
- "key_usage",
- "todo",
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "send_to_tab",
- "read_tab",
- ]),
- )
- .optional()
- .describe(
- "Tool names to give the child. Defaults to the agent definition's tools. Intersected with the spawning agent's tools (you can't grant capabilities you don't have).",
- ),
- working_directory: z
- .string()
- .optional()
- .describe(
- "Absolute path for the child to work in. Defaults to the agent definition's cwd (or the spawning agent's directory).",
- ),
- // `background` is meaningless for fire-and-forget user agents, so the
- // knob is omitted in user-agent-only mode.
- ...(userAgentOnly
- ? {}
- : {
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.",
- ),
- }),
- };
-
- return {
- name: "summon",
- description,
- parameters: z.object(parametersShape),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const task = args.task as string;
- const agentSlug = args.agent as string | undefined;
- const tools = args.tools as string[] | undefined;
- const workingDirectory = args.working_directory as string | undefined;
- const background = (args.background as boolean | undefined) ?? false;
- // User-agent-only mode always spawns top-level user agents. When both
- // capabilities are present the caller chooses via `top_level`. When
- // only subagents are available, top-level spawning is unavailable.
- const topLevel = userAgentOnly
- ? true
- : userAgentEnabled
- ? ((args.top_level as boolean | undefined) ?? false)
- : false;
-
- try {
- const agentId = await callbacks.spawn({
- task,
- tools: tools ?? [],
- ...(workingDirectory ? { workingDirectory } : {}),
- ...(agentSlug ? { agentSlug } : {}),
- ...(topLevel ? { topLevel: true } : {}),
- });
-
- if (topLevel) {
- // User agents are always fire-and-forget — never block on a
- // result and make it explicit that retrieve won't work.
- return [
- `User agent spawned successfully.`,
- `agent_id: ${agentId}`,
- ``,
- `The user agent is now working independently in its own top-level tab.`,
- `It is fire-and-forget — you cannot retrieve its result.`,
- ].join("\n");
- }
-
- if (!background) {
- // Block until the child agent completes. Always prefix the
- // result with `agent_id: <uuid>` so the frontend's
- // ToolCallDisplay regex (`agent_id:\s*([a-f0-9-]+)`) can
- // surface the "Open Tab" button for foreground summons too —
- // not just background ones. The child's tab still exists and
- // holds the full conversation, so the user should always be
- // able to open it.
- const result = await callbacks.getResult(agentId);
- if (result.status === "done") {
- return `agent_id: ${agentId}\n\n${result.result}`;
- }
- return `agent_id: ${agentId}\n\nError from child agent: ${result.error}`;
- }
-
- return [
- `Agent spawned successfully.`,
- `agent_id: ${agentId}`,
- ``,
- `The child agent is now working on the task in its own tab.`,
- `Use the retrieve tool with this agent_id to get the result when ready.`,
- ].join("\n");
- } catch (err) {
- return `Error spawning agent: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
-
-/**
- * Build the `AvailableAgent[]` projection of an `AgentDefinition` list,
- * deriving each entry's readable `path` from its scope+slug.
- */
-function toAvailableAgents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return defs.map((d) => {
- const baseDir =
- d.scope === "global"
- ? globalDir
- : projectDir
- ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents`
- : globalDir;
- return {
- slug: d.slug,
- name: d.name,
- description: d.description,
- path: `${baseDir}/${d.slug}.toml`,
- };
- });
-}
-
-/**
- * Subagent definitions (`is_subagent === true`) — spawned as child tabs.
- */
-export function toAvailableSubagents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return toAvailableAgents(
- defs.filter((d) => d.is_subagent),
- globalDir,
- projectDir,
- );
-}
-
-/**
- * User-agent definitions (`is_subagent !== true`) — spawnable as
- * independent top-level tabs when `perm_user_agent` is granted.
- */
-export function toAvailableUserAgents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return toAvailableAgents(
- defs.filter((d) => d.is_subagent !== true),
- globalDir,
- projectDir,
- );
-}
diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts
deleted file mode 100644
index 98dcf01..0000000
--- a/packages/core/src/tools/task-list.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { z } from "zod";
-import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js";
-
-/**
- * Valid task statuses. Matches opencode's todo lifecycle:
- * - pending not started
- * - in_progress actively working (exactly ONE at a time)
- * - completed finished successfully
- * - cancelled no longer needed
- */
-const VALID_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
-]);
-
-function normalizeStatus(value: unknown): TaskStatus {
- return typeof value === "string" && VALID_STATUSES.has(value as TaskStatus)
- ? (value as TaskStatus)
- : "pending";
-}
-
-/**
- * Declarative, whole-list task store (ported from opencode's `todowrite`).
- *
- * The model never sees ids and never issues per-item mutations. Instead it
- * sends the ENTIRE desired list on every call and {@link setTasks} rebuilds the
- * stored list, assigning fresh positional ids. This is idempotent and
- * eliminates the id-bookkeeping / "task not found" / delta-reasoning failure
- * modes of the old imperative CRUD interface.
- */
-export class TaskList {
- private tasks: TaskItem[] = [];
- private listeners: Array<(tasks: TaskItem[]) => void> = [];
-
- private notify(): void {
- const snapshot = this.getTasks();
- for (const listener of this.listeners) {
- listener(snapshot);
- }
- }
-
- getTasks(): TaskItem[] {
- return this.tasks.map((t) => ({ ...t }));
- }
-
- /**
- * Replace the entire list. Each item is assigned a fresh positional id
- * (`task-1`, `task-2`, …). Invalid/missing statuses fall back to
- * `pending`; an empty array clears the list. Always notifies listeners.
- */
- setTasks(items: Array<{ content: string; status?: unknown }>): TaskItem[] {
- this.tasks = items.map((item, index) => ({
- id: `task-${index + 1}`,
- content: item.content,
- status: normalizeStatus(item.status),
- }));
- this.notify();
- return this.getTasks();
- }
-
- onChange(callback: (tasks: TaskItem[]) => void): () => void {
- this.listeners.push(callback);
- return () => {
- this.listeners = this.listeners.filter((l) => l !== callback);
- };
- }
-}
-
-/**
- * Rich tool description adapted from opencode's `todowrite.txt`. Teaches the
- * declarative whole-list cadence and the status lifecycle.
- */
-export const TODO_DESCRIPTION = `Create and maintain a structured todo list for the current session to track progress and surface your plan to the user.
-
-This is a DECLARATIVE, whole-list tool. There are no ids and no per-item actions: every call sends the ENTIRE list in the \`todos\` parameter and REPLACES the previous list. To change one item, resend the whole list with that item changed. To clear the list, send an empty array.
-
-## When to use
-- The task requires 3+ distinct steps and benefits from planning
-- The user provides multiple tasks (numbered or comma-separated) or asks for a todo list
-- New instructions arrive — capture them as todos
-- You start a task — mark it in_progress (only one at a time) before working
-- You finish a task — mark it completed and add any follow-ups discovered
-
-## When NOT to use
-- A single, straightforward task (or fewer than 3 trivial steps)
-- Purely informational or conversational requests
-- When tracking adds no organizational value
-
-## States
-- pending — not started
-- in_progress — actively working (exactly ONE at a time)
-- completed — finished successfully
-- cancelled — no longer needed
-
-## Rules
-- Send the full desired list every time; the tool replaces the stored list
-- Update status in real time; do not batch completions
-- Mark completed only after the work is actually done (including any required verification), never on intent
-- Keep exactly one in_progress while work remains
-- If blocked or partial, keep it in_progress and add a follow-up todo describing the blocker
-- Items should be specific and actionable; break large work into smaller steps`;
-
-export function createTaskListTool(taskList: TaskList): ToolDefinition {
- return {
- name: "todo",
- description: TODO_DESCRIPTION,
- parameters: z.object({
- todos: z
- .array(
- z.object({
- content: z.string().describe("Brief, actionable description of the task"),
- status: z
- .enum(["pending", "in_progress", "completed", "cancelled"])
- .describe("Current status of the task"),
- }),
- )
- .describe("The complete, updated todo list. Replaces the previous list entirely."),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawTodos = args.todos;
- if (!Array.isArray(rawTodos)) {
- return "Error: 'todos' must be an array of { content, status } items (send the whole list).";
- }
-
- const items: Array<{ content: string; status?: unknown }> = [];
- for (const entry of rawTodos) {
- if (!entry || typeof entry !== "object") {
- return "Error: each todo must be an object with a 'content' string and a 'status'.";
- }
- const content = (entry as Record<string, unknown>).content;
- if (typeof content !== "string" || content.trim() === "") {
- return "Error: each todo requires a non-empty 'content' string.";
- }
- items.push({
- content,
- status: (entry as Record<string, unknown>).status,
- });
- }
-
- const stored = taskList.setTasks(items);
- // Echo the canonical stored list back WITHOUT ids — the model must
- // never start tracking ids; it always resends the whole list.
- const echo = stored.map((t) => ({ content: t.content, status: t.status }));
- return JSON.stringify(echo);
- },
- };
-}
diff --git a/packages/core/src/tools/truncate.ts b/packages/core/src/tools/truncate.ts
deleted file mode 100644
index 8c62174..0000000
--- a/packages/core/src/tools/truncate.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-
-// ─── Constants ───────────────────────────────────────────────────
-//
-// A tool result that exceeds *either* MAX_CHARS or MAX_LINES is treated
-// as oversized: the full content is written to a spill file under
-// /tmp/dispatch/tool-results/<tabId>/<callId>.txt and the model receives
-// HEAD_CHARS from the start + TAIL_CHARS from the end with a notice
-// in between. These are deliberate hardcoded defaults — see the design
-// discussion in notes/plan.md for the rationale.
-
-export const MAX_CHARS = 10_000;
-export const MAX_LINES = 500;
-export const HEAD_CHARS = 1500;
-export const TAIL_CHARS = 1500;
-
-/** Base directory for all tool-result spill files. Per-tab subdirectories live inside. */
-export const SPILL_ROOT = "/tmp/dispatch/tool-results";
-
-// ─── Public API ──────────────────────────────────────────────────
-
-export interface TruncationContext {
- /** Tab the tool call belongs to. Used to scope the spill directory. */
- tabId: string;
- /** Tool call ID, used as the spill file basename. */
- callId: string;
- /** Tool name, included in the truncation notice for human-readable hints. */
- toolName: string;
-}
-
-export interface TruncationResult {
- /** Final string sent to the model. Either the original (when under threshold) or the head+notice+tail excerpt. */
- displayResult: string;
- /** When truncation happened, the absolute path the full output was spilled to. Undefined otherwise. */
- spillPath?: string;
-}
-
-/**
- * Apply universal truncation to a tool result string.
- *
- * If the result is under both the character and line caps, returns it
- * unchanged with no side effects.
- *
- * If the result exceeds either cap:
- * 1. Writes the full content to `<SPILL_ROOT>/<tabId>/<callId>.txt`.
- * 2. Builds a display string consisting of HEAD_CHARS from the start,
- * a multi-line truncation notice that includes the spill path, and
- * TAIL_CHARS from the end.
- *
- * The notice instructs the model to use `read_file` (with offset/limit)
- * or `read_file_slice` to inspect the full content. Every tool result
- * flows through this function via `Agent.executeToolWithStreaming`, so
- * any new tool that returns a string automatically gets the protection.
- */
-export function applyTruncation(result: string, ctx: TruncationContext): TruncationResult {
- const totalChars = result.length;
- const totalLines = countLines(result);
-
- if (totalChars <= MAX_CHARS && totalLines <= MAX_LINES) {
- return { displayResult: result };
- }
-
- const spillPath = join(SPILL_ROOT, ctx.tabId, `${ctx.callId}.txt`);
- try {
- mkdirSync(dirname(spillPath), { recursive: true, mode: 0o700 });
- writeFileSync(spillPath, result, { encoding: "utf-8", mode: 0o600 });
- } catch (err) {
- // If we can't spill (disk full, perms, etc.) fall back to hard-truncating
- // the head + tail without a spill path reference. The model loses the
- // ability to inspect the middle but won't be blocked outright.
- const message = err instanceof Error ? err.message : String(err);
- return {
- displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
- spillPath: null,
- spillError: message,
- }),
- };
- }
-
- return {
- displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
- spillPath,
- spillError: null,
- }),
- spillPath,
- };
-}
-
-/** Delete the entire spill directory for a tab. Best-effort, errors swallowed. */
-export function clearSpillForTab(tabId: string): void {
- const dir = join(SPILL_ROOT, tabId);
- try {
- rmSync(dir, { recursive: true, force: true });
- } catch {
- // Ignore — tab close should not fail on cleanup errors
- }
-}
-
-// ─── Internal helpers ────────────────────────────────────────────
-
-function countLines(s: string): number {
- if (s.length === 0) return 0;
- let count = 1;
- for (let i = 0; i < s.length; i++) {
- if (s.charCodeAt(i) === 10 /* \n */) count++;
- }
- return count;
-}
-
-function buildExcerpt(
- result: string,
- totalChars: number,
- totalLines: number,
- ctx: TruncationContext,
- spill: { spillPath: string | null; spillError: string | null },
-): string {
- // Guard against pathological case where HEAD+TAIL overlap. If the result
- // is between MAX_CHARS and HEAD+TAIL (rare), slice cleanly so we don't
- // emit overlapping content.
- const head = result.slice(0, HEAD_CHARS);
- const tailStart = Math.max(HEAD_CHARS, totalChars - TAIL_CHARS);
- const tail = result.slice(tailStart);
-
- const omittedChars = Math.max(0, totalChars - head.length - tail.length);
-
- const notice: string[] = [
- "",
- `[output truncated by dispatch — tool=${ctx.toolName}, total ${totalChars.toLocaleString()} chars / ${totalLines.toLocaleString()} lines; showing first ${head.length.toLocaleString()} and last ${tail.length.toLocaleString()}, ${omittedChars.toLocaleString()} omitted]`,
- ];
- if (spill.spillPath) {
- notice.push(
- `[full output saved to: ${spill.spillPath}]`,
- `[use read_file with offset/limit (lines), or read_file_slice (chars within a single line), to inspect specific sections]`,
- );
- } else if (spill.spillError) {
- notice.push(`[failed to spill full output to disk: ${spill.spillError}]`);
- }
- notice.push("");
-
- return `${head}${notice.join("\n")}${tail}`;
-}
diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts
deleted file mode 100644
index 7f061a5..0000000
--- a/packages/core/src/tools/web-search.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-
-const FIRECRAWL_URL = "http://100.102.55.49:31329/v1/search";
-const MAX_OUTPUT_CHARS = 60000;
-const TIMEOUT_MS = 30000;
-
-export function createWebSearchTool(): ToolDefinition {
- return {
- name: "web_search",
- description:
- "Search the web via a self-hosted Firecrawl instance. Returns a list of results with titles, URLs, and descriptions. Optionally scrapes the full markdown content of each result page.",
- parameters: z.object({
- query: z.string().describe("The search query"),
- limit: z
- .number()
- .optional()
- .default(7)
- .describe("Maximum number of results to return (default 7)"),
- scrape: z
- .boolean()
- .optional()
- .default(false)
- .describe("Whether to also scrape the full markdown content of each result page"),
- lang: z.string().optional().describe('Language code to filter results (e.g. "en")'),
- country: z.string().optional().describe('Country code to filter results (e.g. "us")'),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const query = args.query as string;
- const limit = (args.limit as number | undefined) ?? 7;
- const scrape = (args.scrape as boolean | undefined) ?? false;
- const lang = args.lang as string | undefined;
- const country = args.country as string | undefined;
-
- const body: Record<string, unknown> = { query, limit };
- if (lang !== undefined) body.lang = lang;
- if (country !== undefined) body.country = country;
- if (scrape) {
- body.scrapeOptions = { formats: ["markdown"], onlyMainContent: true };
- }
-
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
-
- let response: Response;
- try {
- response = await fetch(FIRECRAWL_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- },
- body: JSON.stringify(body),
- signal: controller.signal,
- });
- } catch (err) {
- if (err instanceof Error && err.name === "AbortError") {
- return "Error: Request to Firecrawl timed out after 30 seconds.";
- }
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
- return `Error: Could not connect to Firecrawl at http://100.102.55.49:31329. Is it running?`;
- }
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
- } finally {
- clearTimeout(timeout);
- }
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- return `Error: Firecrawl returned HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`;
- }
-
- let json: {
- data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }>;
- };
- try {
- json = await response.json();
- } catch {
- return "Error: Failed to parse Firecrawl response as JSON";
- }
-
- const results = json.data ?? [];
- if (results.length === 0) {
- return "No results found.";
- }
-
- const parts: string[] = [];
- for (const result of results) {
- const title = result.title ?? "(no title)";
- const url = result.url ?? "";
- const description = result.description ?? "";
- let section = `### ${title}\n${url}\n\n${description}`;
- if (result.markdown) {
- section += `\n\n${result.markdown}`;
- }
- parts.push(section);
- }
-
- let output = parts.join("\n\n---\n\n");
- if (output.length > MAX_OUTPUT_CHARS) {
- output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Output truncated]`;
- }
- return output;
- },
- };
-}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
deleted file mode 100644
index 8a73352..0000000
--- a/packages/core/src/tools/write-file.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { dirname } from "node:path";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-/**
- * Optional hook invoked AFTER a successful write, with the canonicalized
- * absolute path of the file just written. Its returned string (when non-empty)
- * is appended to the tool result. This is how LSP diagnostics are surfaced
- * back to the model on write without coupling `@dispatch/core`'s tools to the
- * API layer or the LSP manager — the host wires an implementation that touches
- * the file through the LSP and formats any diagnostics. Errors thrown here are
- * swallowed so a flaky LSP never fails the write itself.
- */
-export type AfterWriteHook = (absolutePath: string) => Promise<string>;
-
-export function createWriteFileTool(
- workingDirectory: string,
- onAfterWrite?: AfterWriteHook,
-): ToolDefinition {
- return {
- name: "write_file",
- description: "Write content to a file relative to the working directory.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory"),
- content: z.string().describe("Content to write to the file"),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const content = args.content as string;
- // Canonicalize so a workdir-relative path that resolves through
- // symlinks to outside the workdir is detected and blocked. The
- // canonicalize walks up to the nearest existing ancestor when the
- // leaf doesn't exist (typical for write_file), so a path like
- // `workdir/escape-link/new-file.txt` where `escape-link` symlinks
- // to /etc still resolves through the symlink and is caught here.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
-
- if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- try {
- await mkdir(dirname(absolutePath), { recursive: true });
- await writeFile(absolutePath, content, "utf8");
- } catch (err) {
- return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- let result = `Successfully wrote to "${filePath}".`;
- // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a
- // hook failure turn a successful write into an error.
- if (onAfterWrite) {
- try {
- const extra = await onAfterWrite(absolutePath);
- if (extra) result += `\n\n${extra}`;
- } catch {
- /* ignore — diagnostics are advisory */
- }
- }
- return result;
- },
- };
-}
diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts
deleted file mode 100644
index 3a26d6f..0000000
--- a/packages/core/src/tools/youtube-transcribe.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-const TRANSCRIBER_BASE = "http://100.102.55.49:41090";
-const MAX_OUTPUT_CHARS = 60000;
-const REQUEST_TIMEOUT_MS = 30000;
-const MAX_WAIT_MS = 10 * 60 * 1000; // give up after 10 minutes of polling
-
-interface TranscriptResponse {
- status: string;
- video_id?: string;
- full_text?: string;
- segments?: Array<{ text: string; start: number; duration: number }>;
- position?: number;
- estimated_seconds?: number;
- error?: string;
- error_type?: string;
-}
-
-async function fetchTranscript(url: string): Promise<TranscriptResponse> {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
- try {
- const apiUrl = `${TRANSCRIBER_BASE}/api/transcript?url=${encodeURIComponent(url)}`;
- const response = await fetch(apiUrl, { signal: controller.signal });
- if (!response.ok) {
- throw new Error(`Transcriber returned HTTP ${response.status} ${response.statusText}`);
- }
- return (await response.json()) as TranscriptResponse;
- } finally {
- clearTimeout(timeout);
- }
-}
-
-function formatTime(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
-}
-
-function formatTranscript(data: TranscriptResponse): string {
- const segments = data.segments ?? [];
- const segmentsText = segments.map((seg) => `[${formatTime(seg.start)}] ${seg.text}`).join("\n");
-
- const output = [
- `Video ID: ${data.video_id}`,
- "",
- "## Transcript",
- "",
- data.full_text ?? "",
- "",
- "## Timestamped Segments",
- "",
- segmentsText,
- ].join("\n");
-
- return output.length > MAX_OUTPUT_CHARS
- ? `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Transcript truncated]`
- : output;
-}
-
-function sleep(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-/** Polls until the transcript is ready, fails, or times out. */
-async function pollUntilReady(url: string): Promise<string> {
- const startTime = Date.now();
-
- while (Date.now() - startTime < MAX_WAIT_MS) {
- const data = await fetchTranscript(url);
-
- if (data.status === "completed") {
- return formatTranscript(data);
- }
-
- if (data.status === "failed") {
- return `Error: Transcription failed for video ${data.video_id ?? "unknown"}: [${data.error_type ?? "unknown"}] ${data.error ?? "no details"}`;
- }
-
- if (data.status === "queued" || data.status === "processing") {
- const estimate = data.estimated_seconds ?? 30;
- const waitMs = Math.max((estimate - 2) * 1000, 2000);
- await sleep(waitMs);
- continue;
- }
-
- return `Error: Unexpected transcriber response status: ${data.status}`;
- }
-
- return "Error: Timed out waiting for transcript after 10 minutes.";
-}
-
-/** Store for transcript polls backgrounded due to user interrupt. */
-export class BackgroundTranscriptStore {
- private jobs = new Map<string, { url: string; completion: Promise<string> }>();
-
- register(url: string, completion: Promise<string>): string {
- const id = `youtube_transcribe_${randomUUID()}`;
- this.jobs.set(id, { url, completion });
- // Auto-cleanup 10 minutes after completion
- completion.finally(() => {
- setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000);
- });
- return id;
- }
-
- async getResult(
- id: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const job = this.jobs.get(id);
- if (!job) {
- return { status: "error", error: `No background transcript job found with id '${id}'` };
- }
- const result = await job.completion;
- return { status: "done", result };
- }
-
- has(id: string): boolean {
- return this.jobs.has(id);
- }
-}
-
-export function createYoutubeTranscribeTool(
- transcriptStore?: BackgroundTranscriptStore,
-): ToolDefinition {
- return {
- name: "youtube_transcribe",
- description: [
- "Fetch the transcript/subtitles for a YouTube video. This tool blocks until the transcript is ready.",
- "If the video hasn't been transcribed yet, it will be queued and this tool waits for it automatically.",
- "If the user interrupts while waiting, the request continues in the background and you receive a job ID.",
- "Use the retrieve tool with that ID to get the transcript later.",
- "",
- "Accepted URL formats:",
- " - youtube.com/watch?v=",
- " - youtu.be/",
- " - youtube.com/embed/",
- " - youtube.com/shorts/",
- ].join("\n"),
- parameters: z.object({
- url: z.string().describe("The YouTube video URL to fetch the transcript for."),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, the transcription request starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the transcript later.",
- ),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const url = args.url as string;
- const background = (args.background as boolean | undefined) ?? false;
- const queueCallbacks = context?.queueCallbacks;
-
- try {
- const pollPromise = pollUntilReady(url);
-
- // If background mode requested, register immediately and return job ID
- if (background && transcriptStore) {
- const jobId = transcriptStore.register(url, pollPromise);
- return [
- `Transcript request started in background.`,
- `job_id: ${jobId}`,
- `url: ${url}`,
- ``,
- `Use the retrieve tool with this job_id to get the transcript when ready.`,
- ].join("\n");
- }
-
- if (queueCallbacks && transcriptStore) {
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([pollPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- // Background the still-polling request
- const jobId = transcriptStore.register(url, pollPromise);
-
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
-
- return [
- `Transcript request backgrounded — still waiting for transcription.`,
- `job_id: ${jobId}`,
- `url: ${url}`,
- ``,
- `Use the retrieve tool with this job_id to get the transcript when ready.`,
- ``,
- `[USER INTERRUPT]`,
- `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`,
- ``,
- userMessages,
- ].join("\n");
- }
-
- // Poll finished before interrupt
- cancelQueueWait();
- return raceResult;
- }
-
- return await pollPromise;
- } catch (err) {
- if (err instanceof Error && err.name === "AbortError") {
- return "Error: Request to YouTube transcriber timed out.";
- }
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
- return `Error: Could not connect to YouTube transcriber at ${TRANSCRIBER_BASE}. Is it running?`;
- }
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
deleted file mode 100644
index e878813..0000000
--- a/packages/core/src/types/index.ts
+++ /dev/null
@@ -1,672 +0,0 @@
-import type { ZodType } from "zod";
-import type { PermissionChecker, Ruleset } from "../permission/index.js";
-
-// ─── Message Types ───────────────────────────────────────────────
-
-export type MessageRole = "user" | "assistant" | "system";
-
-/**
- * A single ordered chunk of content inside a message. The chunk list
- * preserves the actual temporal ordering of text, reasoning, tool calls,
- * system notices, and errors as they arrived from the model.
- *
- * Coalescing rules (see notes/plan-chunk-refactor.md):
- * - `text` and `thinking` coalesce on consecutive same-type deltas.
- * - `tool-batch` coalesces on consecutive `tool-call` events
- * (appends a new entry to `calls`).
- * - `error` and `system` are always single-event chunks (no coalescing).
- */
-export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk;
-
-export interface TextChunk {
- type: "text";
- text: string;
-}
-
-export interface ThinkingChunk {
- type: "thinking";
- text: string;
- /**
- * Full Anthropic `providerMetadata` blob captured from the v6
- * `reasoning-end` stream event (typically `{ anthropic: { signature
- * } }` plus any other provider-side metadata). Round-tripped verbatim
- * as `providerOptions` on the `ReasoningPart` of the next request so
- * Anthropic can validate the thinking block's signature.
- *
- * Also acts as a "sealed" marker for `appendEventToChunks`: once
- * `metadata` is set, the next `reasoning-delta` opens a new thinking
- * chunk rather than extending this one (each Anthropic content block
- * gets its own metadata, so two consecutive thinking blocks must not
- * be coalesced).
- *
- * Optional: non-Anthropic models produce no metadata, and pre-v6
- * persisted chunks have neither field.
- */
- metadata?: Record<string, unknown>;
-}
-
-export interface ToolBatchChunk {
- type: "tool-batch";
- calls: ToolBatchEntry[];
-}
-
-export interface ToolBatchEntry {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-
-export interface ErrorChunk {
- type: "error";
- message: string;
- statusCode?: number;
-}
-
-export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
-
-export interface SystemChunk {
- type: "system";
- kind: SystemChunkKind;
- text: string;
-}
-
-export interface ChatMessage {
- role: MessageRole;
- chunks: Chunk[];
- /**
- * Ephemeral ORDERED multimodal content for a user turn (interleaved text +
- * image/pdf attachments). Set ONLY transiently on the in-flight user message
- * so `toModelMessages` can emit multimodal `ImagePart`/`FilePart` content to
- * the provider. Never persisted (the chunk log stores only the text, with
- * `[image]`/`[pdf]` markers), so it's absent on history-rebuilt messages.
- * When absent, the message is plain text built from its `chunks`.
- */
- content?: UserContentPart[];
-}
-
-// ─── Multimodal user content (image / PDF attachments) ───────────
-//
-// When a user pastes one or more images/PDFs into the chat input, the turn's
-// user message carries an ORDERED list of content parts instead of a plain
-// string. The ordering is meaningful — the user can interleave text and
-// attachments ("here is image A: <A>, here is image B: <B>") and the model
-// sees them in exactly that sequence.
-//
-// These parts are EPHEMERAL: they are forwarded to the model for the turn that
-// produced them but are NOT persisted as raw bytes in the chunk log. History
-// stores only the user's text (with `[image]` / `[pdf]` markers in place of
-// each attachment), so a later reload re-renders the text but never re-sends
-// the binary payload. This keeps the persisted log small and avoids re-billing
-// image tokens on every subsequent turn.
-
-/** A plain-text segment of a multimodal user message. */
-export interface UserTextPart {
- type: "text";
- text: string;
-}
-
-/**
- * A binary attachment (image or PDF) in a multimodal user message. `data` is a
- * base64-encoded payload (no `data:` URI prefix); `mediaType` is the IANA media
- * type (e.g. `image/png`, `application/pdf`). `name` is an optional original
- * filename, used only for PDF `filename` passthrough and diagnostics.
- */
-export interface UserAttachmentPart {
- type: "attachment";
- /** IANA media type, e.g. `image/png`, `image/jpeg`, `application/pdf`. */
- mediaType: string;
- /** Base64-encoded bytes WITHOUT a `data:` URI prefix. */
- data: string;
- /** Optional original filename (mainly for PDFs). */
- name?: string;
-}
-
-/** One ordered part of a multimodal user message. */
-export type UserContentPart = UserTextPart | UserAttachmentPart;
-
-// ─── Append-only chunk log (persisted model) ─────────────────────
-//
-// The DB stores a conversation as a flat stream of `ChunkRow`s (see
-// db/chunks.ts). The render-facing `Chunk`/`ChatMessage` shapes above are
-// DERIVED from these rows by grouping (turn_id + step + role). Tool calls
-// and their results are SEPARATE rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format.
-
-/** Role of a persisted chunk row. `tool` rows hold tool results. */
-export type ChunkRole = "user" | "assistant" | "tool" | "system";
-
-/** Discriminator for a persisted chunk row's payload. */
-export type ChunkType =
- | "text"
- | "thinking"
- | "tool_call"
- | "tool_result"
- | "error"
- | "system"
- | "usage";
-
-export interface TextData {
- text: string;
-}
-export interface ThinkingData {
- text: string;
- metadata?: Record<string, unknown>;
-}
-export interface ToolCallData {
- callId: string;
- name: string;
- arguments: Record<string, unknown>;
-}
-export interface ToolResultData {
- callId: string;
- name: string;
- result: string;
- isError: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-export interface ErrorData {
- message: string;
- statusCode?: number;
-}
-export interface SystemData {
- kind: SystemChunkKind;
- text: string;
-}
-/**
- * Per-request token usage persisted as a SIDE-CHANNEL chunk row (one row per
- * `usage` AgentEvent, i.e. one per LLM round-trip). These rows are deliberately
- * EXCLUDED from `getChunksForTab`/`getTotalChunkCount` so they never enter the
- * render, pagination, eviction, or agent-history-rebuild paths — they exist
- * only to feed the backend aggregate `getUsageStatsForTab`, which seeds the
- * frontend's `cacheStats` on reload. `inputTokens` is the TOTAL prompt
- * (cached + fresh); `cacheReadTokens`/`cacheWriteTokens` are Anthropic's
- * prompt-cache split. Mirrors the `usage` AgentEvent payload.
- */
-export interface UsageData {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
-}
-
-/**
- * Aggregate per-tab usage telemetry: the cumulative sum across ALL persisted
- * `usage` rows, the request count, and the most recent request's split. This is
- * the server-side source of truth (complete regardless of frontend
- * eviction/pagination) returned by `getUsageStatsForTab`. Structurally
- * identical to the frontend `CacheStats` so it can seed it directly. `null` when
- * the tab has no usage rows.
- */
-export interface UsageStats {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- /** Number of LLM requests (usage rows) counted. */
- requests: number;
- last: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- } | null;
-}
-
-export type ChunkData =
- | TextData
- | ThinkingData
- | ToolCallData
- | ToolResultData
- | ErrorData
- | SystemData
- | UsageData;
-
-/**
- * A persisted chunk row — the append-only unit of conversation storage and
- * the unit of frontend pagination. `seq` is per-tab monotonic and is both the
- * ordering key and the pagination cursor.
- */
-export interface ChunkRow {
- id: string;
- tabId: string;
- seq: number;
- turnId: string;
- step: number;
- role: ChunkRole;
- type: ChunkType;
- data: ChunkData;
- createdAt: number;
-}
-
-/**
- * A chunk-row draft (no `seq`/`tabId`/`createdAt`/`id` yet) used when
- * exploding an in-memory turn into rows for persistence.
- */
-export interface ChunkRowDraft {
- turnId: string;
- step: number;
- role: ChunkRole;
- type: ChunkType;
- data: ChunkData;
-}
-
-export interface ToolCall {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
-}
-
-export interface ToolResult {
- toolCallId: string;
- toolName: string;
- result: string;
- isError: boolean;
-}
-
-// ─── Agent Status & Events ───────────────────────────────────────
-
-export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key";
-
-/**
- * Per-tab snapshot of live state, sent on WS connect and via
- * `GET /status`. Carries enough information for a freshly-loaded
- * frontend to reconstruct any in-flight assistant message.
- *
- * - `status` — always present; mirrors the in-memory `TabAgent.status`.
- * - `currentChunks` — the live in-flight `Chunk[]` for the running
- * assistant turn. Present iff `status === "running"` AND
- * `TabAgent.currentChunks` is non-null. Defensively copied at
- * snapshot time; the consumer owns the array.
- * - `currentAssistantId` — DB id of the in-flight assistant message
- * (the row that the eventual `flushAssistant` call will write/update).
- * Present iff `status === "running"` AND `TabAgent.currentAssistantId`
- * is set. The frontend uses this to align its local assistant
- * message id with the persisted id so subsequent `done` and reload
- * paths line up.
- *
- * Not part of `AgentEvent` itself: the `statuses` payload is a WS-
- * connect-level snapshot, not an event the `Agent` emits. The frontend
- * mirrors this type in `packages/frontend/src/lib/types.ts`.
- */
-export interface TabStatusSnapshot {
- status: AgentStatus;
- currentChunks?: Chunk[];
- currentAssistantId?: string;
- /**
- * `turn_id` of the in-flight turn. Present iff `status === "running"`.
- * Lets a frontend that reconnects mid-stream key its live chunks the same
- * way `turn-start` would, so they reconcile cleanly when the turn seals.
- */
- currentTurnId?: string;
- /**
- * The tab's current todo list. Included for ALL tabs (not just running
- * ones) so a freshly-reloaded frontend rehydrates the Tasks panel from the
- * backend instead of blanking it. Omitted when the list is empty.
- */
- tasks?: TaskItem[];
-}
-
-export type AgentEvent =
- | { type: "status"; status: AgentStatus }
- /**
- * Emitted once at the start of a turn (`processMessage`), before any
- * content deltas. Carries the `turn_id` shared by this turn's user message
- * and every assistant/tool chunk row. The frontend tags its in-flight
- * (live) chunks with this id so they key-match the sealed rows on
- * turn-completion reconcile (no remount/flicker). Display/sync only — not
- * conversation content.
- */
- | { type: "turn-start"; turnId: string }
- /**
- * Emitted once after a turn has fully settled AND its chunks have been
- * persisted (after `flushAssistant`). Signals the frontend that the turn's
- * rows — with real `seq`s — are now durable and can be reloaded, so it can
- * fold its transient live representation into the sealed chunk log. Emitted
- * after `status: idle`/`error` (which fire before the DB write). Display/sync
- * only — not conversation content.
- *
- * Carries `usageStats`: the tab's authoritative usage aggregate read from the
- * DB AFTER the turn's usage rows were written. The frontend REPLACES (not adds)
- * its live `cacheStats` with this, reconciling the live accumulator to the
- * persisted truth every turn. This self-heals the live overshoot that occurs
- * when a rate-limited fallback attempt's usage is streamed live but then
- * discarded server-side (never persisted). `null` ⇒ tab has no usage rows;
- * absent ⇒ leave `cacheStats` untouched (back-compat).
- */
- | { type: "turn-sealed"; turnId: string; usageStats?: UsageStats | null }
- | { type: "text-delta"; delta: string }
- | { type: "reasoning-delta"; delta: string }
- /**
- * Emitted on the v6 `reasoning-end` stream event when it carries
- * `providerMetadata`. `appendEventToChunks` attaches the metadata to
- * the most recent unsealed `thinking` chunk; `toModelMessages` reads
- * it back as `providerOptions` on the next request's `ReasoningPart`.
- */
- | { type: "reasoning-end"; metadata?: Record<string, unknown> }
- | { type: "tool-call"; toolCall: ToolCall }
- | { type: "tool-result"; toolResult: ToolResult }
- | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- /**
- * Per-request token usage, emitted once per LLM round-trip (each
- * `streamText` step) from the AI SDK `finish` stream event. `inputTokens`
- * is the TOTAL prompt size including cached tokens; `cacheReadTokens` is
- * the portion served from Anthropic's prompt cache (a cache HIT) and
- * `cacheWriteTokens` the portion written to it (a cache seed). The "Cache
- * Rate" view aggregates these to show the prompt-cache hit rate. Non-
- * caching providers report zero for the cache fields.
- */
- | {
- type: "usage";
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- };
- }
- | { type: "error"; error: string; statusCode?: number }
- | { type: "notice"; message: string }
- | { type: "model-changed"; keyId: string; modelId: string }
- | { type: "done"; message: ChatMessage }
- | { type: "task-list-update"; tasks: TaskItem[] }
- | { type: "config-reload" }
- | {
- type: "tab-created";
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
- }
- | { type: "message-queued"; tabId: string; messageId: string; message: string }
- | {
- type: "message-consumed";
- tabId: string;
- messageIds: string[];
- /**
- * Why the queue was drained:
- * - "interrupt": consumed mid-turn, folded into a running turn's tool
- * result as a [USER INTERRUPT]. The optimistic bubble collapses into
- * that sealed turn.
- * - "continuation": consumed between turns to START a new turn. The
- * optimistic bubble becomes that new turn's initiating user row.
- * Absent ⇒ treat as "interrupt" (back-compat).
- */
- reason?: "interrupt" | "continuation";
- }
- | { type: "message-cancelled"; tabId: string; messageId: string }
- /**
- * Conversation-compaction lifecycle (UI-driven, not an agent tool). A
- * compaction summarizes a tab's older history into an anchored summary while
- * preserving the most recent turns verbatim.
- *
- * `compaction-started` fires on the temporary placeholder tab when the
- * summary request begins. `compaction-complete` fires when the summary has
- * been generated and the history relocated: the compacted continuation now
- * lives on `sourceTabId` (the canonical id, with its key/model/working-dir
- * preserved), the FULL pre-compaction history was moved to `backupTabId`, and
- * `tempTabId` (the placeholder) should be discarded by the frontend.
- * `compaction-error` reports a failure (or cancellation) on `tempTabId`.
- */
- | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
- | {
- type: "compaction-complete";
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }
- | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
-
-// ─── Tool Types ──────────────────────────────────────────────────
-
-export interface ToolExecuteContext {
- onOutput?: (data: string, stream: "stdout" | "stderr") => void;
- queueCallbacks?: QueueCallbacks;
-}
-
-export interface ToolDefinition {
- name: string;
- description: string;
- parameters: ZodType;
- execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>;
-}
-
-// ─── Agent Configuration ─────────────────────────────────────────
-
-/**
- * Canonical, ordered list of reasoning-effort levels — the SINGLE SOURCE OF
- * TRUTH for effort values across the whole codebase (core LLM call site, API
- * validation, agent TOML persistence, and the frontend UI). Ordered from least
- * to most effort.
- *
- * `none` disables reasoning. `low`/`medium`/`high`/`xhigh` are forwarded
- * verbatim to providers that accept them (OpenAI-compatible `reasoning_effort`,
- * Anthropic adaptive `effort`) — `xhigh` is accepted by newer OpenAI reasoning
- * models. `max` is Dispatch's own top tier, mapped per-provider at the call
- * site (e.g. classic-thinking Claude budget tokens).
- */
-export const REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const;
-
-export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
-
-/**
- * Default effort applied when nothing more specific is configured (no per-model
- * effort and no per-tab selection). Resolution order is
- * per-model → per-tab → this default.
- */
-export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high";
-
-/** Human-readable labels for each effort level (UI display). */
-export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
- none: "Off",
- low: "Low",
- medium: "Medium",
- high: "High",
- xhigh: "X-High",
- max: "Max",
-};
-
-/** Runtime type guard for narrowing an arbitrary value to a `ReasoningEffort`. */
-export function isReasoningEffort(value: unknown): value is ReasoningEffort {
- return typeof value === "string" && (REASONING_EFFORTS as readonly string[]).includes(value);
-}
-
-export interface AgentConfig {
- model: string;
- apiKey: string;
- baseURL: string;
- systemPrompt: string;
- tools: ToolDefinition[];
- workingDirectory: string;
- permissionChecker?: PermissionChecker;
- ruleset?: Ruleset;
- reasoningEffort?: ReasoningEffort;
- provider?: string;
- claudeCredentials?: {
- accessToken: string;
- };
- /**
- * Tab ID the agent runs on. Used to scope per-tab side effects, namely
- * the tool-output spill directory (`/tmp/dispatch/tool-results/<tabId>/`).
- * Optional so legacy callers and tests can construct an Agent without one;
- * a fallback ID is generated when absent.
- */
- tabId?: string;
-}
-
-// ─── Config Types (dispatch.toml) ────────────────────────────────
-
-export interface DispatchConfig {
- keys?: KeyDefinition[];
- permissions: Record<string, string | Record<string, string>>;
- /**
- * Language Server Protocol servers, keyed by an arbitrary server id (e.g.
- * `"luau-lsp"`). Resolved by merging the HOME-directory global
- * `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the
- * `dispatch.toml` in a tab's effective working directory — local entries
- * override global ones sharing the same id, and global-only servers stay
- * active in every repository. Re-consulted when either config (or the
- * directory) changes. Config-driven only — there is no builtin server
- * registry and no auto-download; the declared `command[0]` must be on PATH.
- */
- lsp?: Record<string, LspServerConfig>;
-}
-
-/**
- * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.<id>]`
- * block. Mirrors opencode's custom-server schema so the Roblox Luau config
- * (and any other server) is portable between the two.
- *
- * Example (`dispatch.toml`):
- * ```toml
- * [lsp.luau-lsp]
- * command = ["luau-lsp", "lsp", "--definitions=globalTypes.d.luau", "--docs=api-docs.json"]
- * extensions = [".luau"]
- *
- * [lsp.luau-lsp.initialization.luau-lsp.platform]
- * type = "roblox"
- * ```
- */
-export interface LspServerConfig {
- /**
- * Argv to launch the server over stdio. `command[0]` is the executable
- * (resolved via PATH); the rest are arguments. Required for every non-
- * disabled entry.
- */
- command: string[];
- /**
- * File extensions (with leading dot, e.g. `".luau"`) this server attaches
- * to. Required for custom servers — without it the client never knows which
- * files should activate the server.
- */
- extensions: string[];
- /** Extra environment variables merged onto `process.env` for the child. */
- env?: Record<string, string>;
- /**
- * `initializationOptions` forwarded verbatim in the LSP `initialize`
- * request (and echoed back for `workspace/configuration` /
- * `didChangeConfiguration`). For luau-lsp this carries the
- * `{ "luau-lsp": { platform, sourcemap, types, diagnostics, ... } }` block.
- */
- initialization?: Record<string, unknown>;
- /** When true, the entry is parsed but skipped (no server launched). */
- disabled?: boolean;
-}
-
-export interface KeyDefinition {
- id: string;
- provider: string;
- env?: string;
- base_url: string;
- /** For "anthropic" provider: path to credentials file (default: ~/.claude/.credentials.json) */
- credentials_file?: string;
-}
-
-export type KeyStatus = "active" | "exhausted";
-
-export interface KeyState {
- definition: KeyDefinition;
- status: KeyStatus;
- lastError?: string;
- exhaustedAt?: number;
-}
-
-// ─── Skills Types ────────────────────────────────────────────────
-
-export type SkillScope = "global" | "project";
-export type SkillDirectory = string;
-
-export interface SkillDefinition {
- name: string;
- description: string;
- tags: string[];
- content: string;
- scope: SkillScope;
- source: string;
- directory: SkillDirectory;
-}
-
-export interface AgentSkillMapping {
- agentType: string;
- isOrchestrator: boolean;
- skills: string[];
- scope: SkillScope;
-}
-
-// ─── Task List Types ─────────────────────────────────────────────
-
-export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled";
-
-export interface TaskItem {
- /**
- * Stable positional id used purely for UI keying and the
- * `task-list-update` event contract. It is NEVER exposed to the model:
- * the `todo` tool is a declarative whole-list write (the model sends the
- * entire desired list every call), so there are no ids for the model to
- * track. Ids are reassigned positionally on every `setTasks`.
- */
- id: string;
- content: string;
- status: TaskStatus;
-}
-
-// ─── Config Validation ───────────────────────────────────────────
-
-export interface ConfigError {
- path: string;
- message: string;
-}
-
-// ─── Message Queue Types ─────────────────────────────────────────
-
-export interface QueuedMessage {
- id: string;
- message: string;
- timestamp: number;
-}
-
-export interface QueueCallbacks {
- dequeueMessages: () => QueuedMessage[];
- waitForQueuedMessage: () => { promise: Promise<void>; cancel: () => void };
-}
-
-// ─── Agent Definition Types ──────────────────────────────────────
-
-export interface AgentModelEntry {
- key_id: string;
- model_id: string;
- /**
- * Per-model/key reasoning effort. When set, overrides the per-tab effort
- * selector for generations that use this entry (resolution order:
- * per-model → per-tab → DEFAULT_REASONING_EFFORT). Omitted when unset.
- */
- effort?: ReasoningEffort;
-}
-
-export interface AgentDefinition {
- /** Human-readable name */
- name: string;
- /** Short description of what this agent does */
- description: string;
- /** Skills to auto-include, as "scope:name" strings */
- skills: string[];
- /** Allowed tools (allowlist) */
- tools: string[];
- /** Key+model fallback hierarchy, tried in order */
- models: AgentModelEntry[];
- /** Where the TOML was loaded from: "global" or a directory path */
- scope: string;
- /** The slug (filename without .toml) */
- slug: string;
- /** Default working directory for this agent (optional, absolute path) */
- cwd?: string;
- /** Whether this agent is a subagent (hidden from Chat Settings) */
- is_subagent?: boolean;
-}
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
deleted file mode 100644
index 797aea2..0000000
--- a/packages/core/tests/agent/agent.test.ts
+++ /dev/null
@@ -1,1791 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { z } from "zod";
-import type { AgentConfig, AgentEvent } from "../../src/types/index.js";
-
-// Mock bun:sqlite to avoid Bun-only import in vitest/Node
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => ({})),
-}));
-
-// Mock the credentials module that depends on the DB
-vi.mock("../../src/credentials/claude.js", () => ({
- buildBillingHeaderValue: vi.fn(() => ""),
- SYSTEM_IDENTITY: "You are a test agent.",
-}));
-
-// Mock the ai module's streamText
-vi.mock("ai", async () => {
- const actual = await import("ai");
- return {
- ...actual,
- streamText: vi.fn(),
- };
-});
-
-// Mock the provider
-vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: vi.fn(() => (_model: string) => ({
- type: "language-model",
- modelId: _model,
- })),
-}));
-
-const { Agent, anthropicThinkingProviderOptions } = await import("../../src/agent/agent.js");
-const { streamText } = await import("ai");
-
-function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
- return {
- model: "test-model",
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- systemPrompt: "You are a helpful assistant.",
- tools: [],
- workingDirectory: "/tmp",
- ...overrides,
- };
-}
-
-async function* makeFullStream(
- events: Array<{ type: string; [key: string]: unknown }>,
-): AsyncGenerator<{ type: string; [key: string]: unknown }> {
- for (const event of events) {
- yield event;
- }
-}
-
-function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) {
- return {
- fullStream: makeFullStream(events),
- } as ReturnType<typeof import("ai").streamText>;
-}
-
-// v6 finish event — only finishReason, rawFinishReason, totalUsage (no usage/providerMetadata/response)
-const finishStop = {
- type: "finish",
- finishReason: "stop",
- rawFinishReason: "stop",
- totalUsage: { inputTokens: 10, outputTokens: 5 },
-};
-
-const finishToolCalls = {
- type: "finish",
- finishReason: "tool-calls",
- rawFinishReason: "tool_use",
- totalUsage: { inputTokens: 10, outputTokens: 5 },
-};
-
-describe("Agent", () => {
- it("starts in idle status", () => {
- const agent = new Agent(makeConfig());
- expect(agent.status).toBe("idle");
- });
-
- it("has empty messages initially", () => {
- const agent = new Agent(makeConfig());
- expect(agent.messages).toHaveLength(0);
- });
-
- it("yields running then idle status events around a simple message", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: text-delta uses `text` (not `textDelta`)
- { type: "text-delta", id: "t0", text: "Hello!" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("hi")) {
- events.push(event);
- }
-
- const types = events.map((e) => e.type);
- expect(types[0]).toBe("status");
- expect(events[0]).toMatchObject({ type: "status", status: "running" });
-
- const lastStatusEvent = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatusEvent).toMatchObject({ type: "status", status: "idle" });
- });
-
- it("yields text-delta events", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: text-delta uses `text` (not `textDelta`)
- { type: "text-delta", id: "t0", text: "Hello" },
- { type: "text-delta", id: "t0", text: " world" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("test")) {
- events.push(event);
- }
-
- const textDeltas = events.filter((e) => e.type === "text-delta");
- expect(textDeltas).toHaveLength(2);
- expect(textDeltas[0]).toMatchObject({ delta: "Hello" });
- expect(textDeltas[1]).toMatchObject({ delta: " world" });
- });
-
- it("adds user message and assistant message to history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Response" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("my question")) {
- // consume generator
- }
-
- expect(agent.messages).toHaveLength(2);
- expect(agent.messages[0]).toMatchObject({
- role: "user",
- chunks: [{ type: "text", text: "my question" }],
- });
- expect(agent.messages[1]).toMatchObject({
- role: "assistant",
- chunks: [{ type: "text", text: "Response" }],
- });
- });
-
- it("yields done event with final message", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done!" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("test")) {
- events.push(event);
- }
-
- const doneEvent = events.find((e) => e.type === "done");
- expect(doneEvent).toBeDefined();
- expect(doneEvent).toMatchObject({
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "Done!" }] },
- });
- });
-
- it("yields tool-call and tool-result events", async () => {
- // First call: LLM emits a tool-call
- // Second call (after tool execution): LLM emits text response with no tool calls
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- // v6: `input` replaces `args`
- input: { path: "hello.txt" },
- },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "Here is the file." },
- finishStop,
- ]),
- );
-
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (_args: Record<string, unknown>) => "file contents",
- };
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- const events = [];
- for await (const event of agent.run("read the file")) {
- events.push(event);
- }
-
- const toolCallEvent = events.find((e) => e.type === "tool-call");
- expect(toolCallEvent).toMatchObject({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read_file" },
- });
-
- const toolResultEvent = events.find((e) => e.type === "tool-result");
- expect(toolResultEvent).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc1", result: "file contents" },
- });
- });
-
- it("does NOT swallow trailing queued messages into history at turn end", async () => {
- // Regression for the "queue not consumed after the turn ends" bug. A
- // message that lands on the queue after the last tool call (here: a
- // no-tool turn) must be LEFT on the queue for the orchestrator to start
- // a new turn — not silently appended to history with no response.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const queue = [{ id: "q1", message: "answer me next", timestamp: 1 }];
- const dequeueMessages = vi.fn(() => queue.splice(0, queue.length));
- const agent = new Agent(makeConfig(), {
- dequeueMessages,
- waitForQueuedMessage: () => ({ promise: Promise.resolve(), cancel: () => {} }),
- });
-
- const before = agent.messages.length;
- for await (const _ of agent.run("hello")) {
- // consume
- }
-
- // The agent appended exactly the user turn + its own assistant reply;
- // it did NOT drain the queue or append a trailing user message for it.
- expect(dequeueMessages).not.toHaveBeenCalled();
- expect(queue).toHaveLength(1);
- const added = agent.messages.slice(before);
- expect(added.map((m) => m.role)).toEqual(["user", "assistant"]);
- expect(
- added.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "answer me next")),
- ).toBe(false);
- });
-
- it("still injects a mid-turn queued message into the last tool result", async () => {
- // The interrupt path (site 1) must be untouched by the turn-end fix: a
- // message present DURING a tool batch is folded into that batch's last
- // tool result as a [USER INTERRUPT], and the agent loops back to the LLM.
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "tc1", toolName: "read_file", input: { path: "a.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const queue = [{ id: "q1", message: "stop and do X", timestamp: 1 }];
- const dequeueMessages = vi.fn(() => queue.splice(0, queue.length));
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async () => "file contents",
- };
- const agent = new Agent(makeConfig({ tools: [toolDef] }), {
- dequeueMessages,
- waitForQueuedMessage: () => ({ promise: Promise.resolve(), cancel: () => {} }),
- });
-
- const events: AgentEvent[] = [];
- for await (const event of agent.run("read it")) {
- events.push(event);
- }
-
- expect(dequeueMessages).toHaveBeenCalled();
- const toolResult = events.find((e) => e.type === "tool-result") as
- | (AgentEvent & { toolResult: { result: string } })
- | undefined;
- expect(toolResult?.toolResult.result).toContain("[USER INTERRUPT]");
- expect(toolResult?.toolResult.result).toContain("stop and do X");
- });
-
- it("yields reasoning-delta events", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: reasoning-delta uses `text` (not `textDelta`)
- { type: "reasoning-delta", id: "r0", text: "thinking about this..." },
- { type: "reasoning-delta", id: "r0", text: " more thoughts" },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningDeltas = events.filter((e) => e.type === "reasoning-delta");
- expect(reasoningDeltas).toHaveLength(2);
- expect(reasoningDeltas[0]).toMatchObject({ delta: "thinking about this..." });
- expect(reasoningDeltas[1]).toMatchObject({ delta: " more thoughts" });
- });
-
- it("yields reasoning-end event when providerMetadata is present", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "some reasoning" },
- {
- type: "reasoning-end",
- id: "r0",
- providerMetadata: { anthropic: { signature: "sig-1" } },
- },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
- expect(reasoningEndEvent).toBeDefined();
- expect(reasoningEndEvent).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "sig-1" } },
- });
- });
-
- it("does NOT yield reasoning-end event when providerMetadata is absent", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "some reasoning" },
- {
- type: "reasoning-end",
- id: "r0",
- // No providerMetadata — non-Anthropic model
- },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
- expect(reasoningEndEvent).toBeUndefined();
- });
-
- // ─── New v6 round-trip tests ──────────────────────────────────────────────
-
- it("signed thinking round-trip: ThinkingChunk.metadata → ReasoningPart.providerOptions", async () => {
- // Pre-seed the agent with a prior assistant message containing a ThinkingChunk
- // with metadata (the Anthropic signature blob).
- // Anthropic-path provider — for openai-compatible the metadata
- // would be lifted into providerOptions.openaiCompatible instead;
- // that path is covered by the DeepSeek tests further down.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "prior user message" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "thinking",
- text: "I thought about it",
- metadata: { anthropic: { signature: "S" } },
- },
- { type: "text", text: "prior response" },
- ],
- });
-
- // Next turn: just return a simple text response
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "New answer" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- // Inspect the messages passed to streamText in this (last) call
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs).toBeDefined();
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- }>;
-
- // Find the assistant message in the rebuilt ModelMessage[]
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
- const reasoningPart = content.find((p) => p.type === "reasoning");
- expect(reasoningPart).toBeDefined();
- expect(reasoningPart).toMatchObject({
- type: "reasoning",
- text: "I thought about it",
- providerOptions: { anthropic: { signature: "S" } },
- });
- });
-
- it("tool-call input round-trip: ToolBatchEntry.arguments → ToolCallPart.input (not args)", async () => {
- // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-1",
- name: "read_file",
- arguments: { path: "/foo/bar.txt" },
- result: "file contents",
- },
- ],
- },
- ],
- });
-
- // Next turn: just return a simple text response
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The assistant message should contain a tool-call part with `input` (not `args`)
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
- const toolCallPart = content.find((p) => p.type === "tool-call");
- expect(toolCallPart).toBeDefined();
- expect(toolCallPart).toMatchObject({
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "read_file",
- input: { path: "/foo/bar.txt" }, // v6: input not args
- });
- // Explicitly assert `args` is NOT present
- expect(toolCallPart).not.toHaveProperty("args");
- });
-
- it("tool-result output round-trip: result string → { type: 'text', value } ToolResultOutput", async () => {
- // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-2",
- name: "read_file",
- arguments: { path: "/foo/baz.txt" },
- result: "the file content here",
- },
- ],
- },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The tool message should contain a tool-result part with `output` (ToolResultOutput)
- const toolMsg = messages.find((m) => m.role === "tool");
- expect(toolMsg).toBeDefined();
- const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
- expect(toolContent[0]).toMatchObject({
- type: "tool-result",
- toolCallId: "call-2",
- toolName: "read_file",
- output: { type: "text", value: "the file content here" },
- });
- // Explicitly assert `result` (v4 raw string) is NOT present
- expect(toolContent[0]).not.toHaveProperty("result");
- });
-
- it("per-step segmentation: a [tool-batch, text] turn becomes [assistant(tool-call), tool(result), assistant(text)]", async () => {
- // `toModelMessages` segments a turn at each tool-batch boundary, so the
- // tool-batch (step 0) and the trailing text (step 1) land in SEPARATE
- // assistant messages — never a single invalid [tool_use, text] block.
- // This is the cache-stability fix and is applied for every provider.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool and explain" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- // Note: tool-batch appears BEFORE text in chunks — this is the
- // problematic ordering that Anthropic rejects
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-3",
- name: "read_file",
- arguments: { path: "/tmp/x.txt" },
- result: "x contents",
- },
- ],
- },
- { type: "text", text: "Here is my explanation." },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // No assistant message may mix tool-call and non-tool-call parts (the
- // invalid shape Anthropic rejects); segmentation guarantees this.
- const assistantMsgs = messages.filter((m) => m.role === "assistant");
- for (const m of assistantMsgs) {
- const c = m.content as Array<Record<string, unknown>>;
- if (!Array.isArray(c)) continue;
- const hasToolCall = c.some((p) => p.type === "tool-call");
- const hasNonToolCall = c.some((p) => p.type !== "tool-call");
- expect(hasToolCall && hasNonToolCall).toBe(false);
- }
-
- // The seeded turn yields a tool-call assistant message immediately
- // followed by its tool-result message (valid tool_use → tool_result).
- const toolOnlyIdx = messages.findIndex((m) => {
- const c = m.content as Array<Record<string, unknown>>;
- return m.role === "assistant" && Array.isArray(c) && c.some((p) => p.type === "tool-call");
- });
- expect(toolOnlyIdx).toBeGreaterThanOrEqual(0);
- expect(messages[toolOnlyIdx + 1]?.role).toBe("tool");
- });
-
- it("per-step segmentation also applies to the openai-compatible provider", async () => {
- // Segmentation is provider-agnostic: a [tool-batch, text] turn is split
- // into separate assistant messages for openai-compatible too, with the
- // tool result in its own tool message (the standard OpenAI shape).
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool and explain" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-4",
- name: "read_file",
- arguments: { path: "/tmp/y.txt" },
- result: "y contents",
- },
- ],
- },
- { type: "text", text: "Here is my explanation." },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The seeded [tool-batch, text] turn is segmented: a tool-call-only
- // assistant message, its tool message, and a separate text assistant
- // message (the new turn's "ok" reply adds one more). No assistant
- // message mixes tool-call and non-tool-call parts.
- const assistantMsgs = messages.filter((m) => m.role === "assistant");
- for (const m of assistantMsgs) {
- const c = m.content as Array<Record<string, unknown>>;
- if (!Array.isArray(c)) continue;
- expect(c.some((p) => p.type === "tool-call") && c.some((p) => p.type !== "tool-call")).toBe(
- false,
- );
- }
- expect(messages.some((m) => m.role === "tool")).toBe(true);
- const toolCallMsg = assistantMsgs.find((m) => {
- const c = m.content as Array<Record<string, unknown>>;
- return Array.isArray(c) && c.some((p) => p.type === "tool-call");
- });
- expect(toolCallMsg).toBeDefined();
- });
-
- it("empty-text-part filter (Anthropic): empty text chunk is not sent", async () => {
- // Pre-seed an assistant message where a text chunk has empty text.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "hello" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- { type: "text", text: "" }, // empty text — should be filtered out
- { type: "text", text: "non-empty response" },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
-
- // Empty text part should have been filtered out
- const emptyTextParts = content.filter((p) => p.type === "text" && p.text === "");
- expect(emptyTextParts).toHaveLength(0);
-
- // The non-empty text part should still be there
- const nonEmptyTextParts = content.filter((p) => p.type === "text" && p.text !== "");
- expect(nonEmptyTextParts).toHaveLength(1);
- expect(nonEmptyTextParts[0]).toMatchObject({ text: "non-empty response" });
- });
-
- it("empty-reasoning-part filter (Anthropic): empty reasoning chunk is not sent", async () => {
- // Anthropic's adaptive thinking mode occasionally produces a signed-
- // but-empty thinking block. We persist it (for signature round-trip
- // fidelity) but strip the empty `reasoning` part before sending it
- // back, or Anthropic rejects with "thinking block must have content".
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({ role: "user", chunks: [{ type: "text", text: "hi" }] });
- agent.messages.push({
- role: "assistant",
- chunks: [
- // Signed-but-empty thinking block
- { type: "thinking", text: "", metadata: { anthropic: { signature: "sig-empty" } } },
- { type: "text", text: "answer" },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
-
- // Empty reasoning part must have been filtered out by the
- // Anthropic structural normalisation pass.
- const emptyReasoning = content.filter((p) => p.type === "reasoning" && p.text === "");
- expect(emptyReasoning).toHaveLength(0);
-
- // The text part should still be there
- expect(content.some((p) => p.type === "text" && p.text === "answer")).toBe(true);
- });
-
- it("toolCallId scrubbing (Anthropic): non-[a-zA-Z0-9_-] chars in tool IDs are sanitised", async () => {
- // Anthropic rejects toolCallId outside [a-zA-Z0-9_-]. Our internal
- // crypto.randomUUID IDs are safe, but defensively scrub for any
- // upstream-assigned IDs (subagent retrieval, provider-executed
- // tools, MCP, etc.). Mirrors opencode transform.ts:96-122.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({ role: "user", chunks: [{ type: "text", text: "do the thing" }] });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call.with/dots:and:slashes", // invalid chars
- name: "fake_tool",
- arguments: { x: 1 },
- result: "ok",
- isError: false,
- },
- ],
- },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // Assistant tool-call part must have scrubbed ID
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const assistantContent = assistantMsg?.content as Array<Record<string, unknown>>;
- const toolCallPart = assistantContent.find((p) => p.type === "tool-call");
- expect(toolCallPart).toBeDefined();
- expect(toolCallPart?.toolCallId).toBe("call_with_dots_and_slashes");
-
- // Matching tool-result message must use the SAME scrubbed ID
- // so Anthropic can pair them.
- const toolMsg = messages.find((m) => m.role === "tool");
- const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
- expect(toolContent?.[0]?.toolCallId).toBe("call_with_dots_and_slashes");
- });
-
- it("reasoning metadata captured from stream is round-tripped on the next turn", async () => {
- // End-to-end integrity for the providerMetadata round-trip — the
- // bug that prompted the entire migration. Stream a turn that
- // emits reasoning-delta + reasoning-end with metadata, then run
- // ANOTHER turn and verify the metadata reaches the model via
- // ReasoningPart.providerOptions.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- const sig = { anthropic: { signature: "round-trip-sig-1" } };
-
- // Turn 1: model emits reasoning + signed reasoning-end
- vi.mocked(streamText).mockReturnValueOnce(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "let me think" },
- { type: "reasoning-end", id: "r0", providerMetadata: sig },
- { type: "text-delta", id: "t0", text: "answer" },
- finishStop,
- ]),
- );
-
- for await (const _ of agent.run("first question")) {
- /* consume */
- }
-
- // After turn 1, the persisted chunks should include a ThinkingChunk
- // with the captured metadata. The agent's messages array IS the
- // canonical persisted shape (the DB just JSON-stringifies it).
- const turn1Assistant = agent.messages.find(
- (m, i) => m.role === "assistant" && i === agent.messages.length - 1,
- );
- expect(turn1Assistant).toBeDefined();
- const thinkingChunk = turn1Assistant?.chunks.find((c) => c.type === "thinking");
- expect(thinkingChunk).toBeDefined();
- expect(thinkingChunk).toMatchObject({ text: "let me think", metadata: sig });
-
- // Turn 2: drive another turn, capture what streamText receives.
- vi.mocked(streamText).mockReturnValueOnce(
- makeMockStreamResult([
- { type: "text-delta", id: "t1", text: "follow-up answer" },
- finishStop,
- ]),
- );
- for await (const _ of agent.run("second question")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const turn2Assistant = messages
- .filter((m) => m.role === "assistant")
- .find((m) => Array.isArray(m.content));
- const turn2Content = turn2Assistant?.content as Array<Record<string, unknown>>;
- const reasoningPart = turn2Content.find((p) => p.type === "reasoning");
- expect(reasoningPart).toBeDefined();
- expect(reasoningPart).toMatchObject({
- type: "reasoning",
- text: "let me think",
- providerOptions: sig,
- });
- });
-
- it("tool-error stream event yields a synthetic tool-result + error chunk and continues the turn", async () => {
- // Provider-executed tools (Anthropic server tools) bypass our
- // manual executor and surface as a `tool-error` stream event.
- // We must:
- // 1. Synthesize a tool-result with isError=true so the chunks
- // reflect that the tool ran and failed — this keeps the
- // tool-call/tool-result pairing complete and avoids the AI SDK
- // throwing MissingToolResultsError on the next round-trip.
- // 2. Emit an error chunk so the UI shows the failure.
- // 3. NOT transition to "error" status — the step breaks out of the
- // stream loop and the turn ends normally (here, with no further
- // tool calls pending, the agent completes to idle).
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- {
- type: "tool-error",
- toolCallId: "tc_server",
- toolName: "server_tool",
- error: new Error("upstream tool failure"),
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("trigger")) {
- events.push(event);
- }
-
- // Synthetic tool-result with the upstream error
- const trEvent = events.find((e) => e.type === "tool-result");
- expect(trEvent).toBeDefined();
- expect(trEvent).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc_server", isError: true },
- });
-
- // Error chunk for visibility
- const errEvent = events.find((e) => e.type === "error");
- expect(errEvent).toBeDefined();
- const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
- expect(typeof errMsg).toBe("string");
- expect((errMsg as string).includes("upstream tool failure")).toBe(true);
-
- // Status does NOT transition to error — the turn completes to idle.
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
-
- // The turn produced a `done` event (it did not abort).
- expect(events.some((e) => e.type === "done")).toBe(true);
- });
-
- it("tool-error leaves sibling tool calls to be resolved by the executor (not orphaned)", async () => {
- // When one tool in a batch errors, its siblings — whose tool-call
- // events were already yielded — must still receive a result, otherwise
- // the tool-call IDs are orphaned in the chunks (no matching result)
- // and the next LLM round-trip throws MissingToolResultsError. The
- // tool-error handler breaks out of the stream loop WITHOUT executing
- // the unresolved siblings inline; the normal manual-executor pass then
- // runs them. Here `sibling_tool` is not a registered tool, so the
- // executor returns an "Unknown tool" error result — completing the
- // tool-call/tool-result pairing with `isError: true`.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "tc_sibling",
- toolName: "sibling_tool",
- input: {},
- },
- {
- type: "tool-error",
- toolCallId: "tc_failed",
- toolName: "failed_tool",
- error: new Error("boom"),
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("trigger")) {
- events.push(event);
- }
-
- const toolResults = events.filter((e) => e.type === "tool-result");
- // One for the failed tool, one for the sibling resolved by the executor.
- const siblingResult = toolResults.find(
- (e) => "toolResult" in e && e.toolResult.toolCallId === "tc_sibling",
- );
- expect(siblingResult).toBeDefined();
- expect(siblingResult).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc_sibling", isError: true },
- });
- const siblingMsg =
- siblingResult && "toolResult" in siblingResult ? siblingResult.toolResult.result : "";
- expect((siblingMsg as string).includes("sibling_tool")).toBe(true);
-
- // Status completes to idle (the turn continued, not aborted).
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
- });
-
- it("abort stream event surfaces as an error event and stops the turn", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "starting..." },
- { type: "abort", reason: "user cancelled" },
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("hi")) {
- events.push(event);
- }
-
- const errEvent = events.find((e) => e.type === "error");
- expect(errEvent).toBeDefined();
- const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
- expect(
- (errMsg as string).toLowerCase().includes("aborted") ||
- (errMsg as string).includes("user cancelled"),
- ).toBe(true);
-
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "error" });
- });
-
- it("openai-compatible reasoning round-trip: ThinkingChunk -> providerOptions.openaiCompatible.reasoning_content (DeepSeek scenario)", async () => {
- // Reproducer for the "reasoning_content must be passed back" error
- // from DeepSeek via OpenCode Go.
- //
- // applyOpenAICompatibleReasoningNormalisation strips the
- // `{ type: "reasoning", text }` parts and lifts the concatenated
- // text into `providerOptions.openaiCompatible.reasoning_content`.
- // The v6 SDK provider serializes the message-level
- // `providerOptions.openaiCompatible.*` into the wire `assistant`
- // message via its `metadata` spread (line 247 of the SDK dist).
- // This route emits `reasoning_content` regardless of empty/non-
- // empty text — which is what DeepSeek requires.
- const agent = new Agent(
- makeConfig({
- model: "deepseek-v4-pro",
- // no provider field → default openai-compatible path
- }),
- );
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "ping" }] },
- {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "let me reason about this" },
- { type: "text", text: "ok done" },
- ],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- if (!assistantMsg || !Array.isArray(assistantMsg.content)) {
- throw new Error("expected structured assistant content");
- }
- const content = assistantMsg.content as Array<Record<string, unknown>>;
-
- // Reasoning parts have been stripped from content (lifted into
- // providerOptions instead).
- expect(content.find((p) => p.type === "reasoning")).toBeUndefined();
-
- // reasoning_content is set on providerOptions.openaiCompatible.
- // This is what reaches DeepSeek and prevents the rejection.
- expect(assistantMsg.providerOptions?.openaiCompatible?.reasoning_content).toBe(
- "let me reason about this",
- );
-
- // The text part still survives in content.
- const textPart = content.find((p) => p.type === "text");
- expect(textPart).toMatchObject({ type: "text", text: "ok done" });
-
- // And critically, the message must NOT carry a providerMetadata
- // key (the v4-era misnamed key). v3 prompts use `providerOptions`.
- expect((assistantMsg as Record<string, unknown>).providerMetadata).toBeUndefined();
- });
-
- it("openai-compatible empty-reasoning edge case: forces reasoning_content='' so DeepSeek does not reject", async () => {
- // DeepSeek will reject the follow-up turn with "must be passed
- // back" if a prior assistant turn emitted reasoning AND the
- // follow-up doesn't include `reasoning_content` (even empty).
- // The v6 SDK's content-side path skips emission when reasoning
- // is empty (see `dist/index.mjs:245`); our normalisation routes
- // it via providerOptions instead, which fires unconditionally.
- const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "ping" }] },
- {
- role: "assistant",
- chunks: [
- // Empty thinking — captured but produced no actual text.
- { type: "thinking", text: "" },
- { type: "text", text: "answer" },
- ],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- if (!assistantMsg) throw new Error("expected assistant message");
-
- // The empty-string reasoning_content is explicitly set on
- // providerOptions. (`""` is intentional and required —
- // `assistantMsg.providerOptions?.openaiCompatible?.reasoning_content`
- // must not be `undefined`.)
- const rc = assistantMsg.providerOptions?.openaiCompatible?.reasoning_content;
- expect(rc).toBeDefined();
- expect(rc).toBe("");
- });
-
- it("openai-compatible normalisation does NOT run for messages without any reasoning parts", async () => {
- // DeepSeek only requires `reasoning_content` AFTER a thinking
- // turn. For purely-text assistant messages, we should leave
- // providerOptions alone.
- const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "hi" }] },
- {
- role: "assistant",
- chunks: [{ type: "text", text: "hello back" }],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("again")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
-
- // No reasoning chunks → no providerOptions injection. (May still
- // be undefined entirely if nothing else set it.)
- const rc = assistantMsg?.providerOptions?.openaiCompatible?.reasoning_content;
- expect(rc).toBeUndefined();
- });
-
- // ─── Prompt-caching: tool-result grouping & breakpoints (notes/claude-report.md) ──
-
- it("groups a turn's tool results into a SINGLE role:'tool' message (Root Cause 2)", async () => {
- // The agent batches three distinct read_file calls in one step. The
- // rebuilt ModelMessage[] must contain exactly ONE `role: "tool"` message
- // holding all three results (not three separate tool messages). Per-
- // result messages would strand the rolling cache breakpoints on the last
- // two adjacent tool results, wasting a breakpoint.
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "b1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "b2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "b3", toolName: "read_file", input: { path: "c.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
- for await (const _ of agent.run("read three files")) {
- /* consume */
- }
-
- // Inspect the step-1 request (sent after the batch executed) — its tail
- // is the assistant tool-calls + the grouped tool results.
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- const toolMsgs = messages.filter((m) => m.role === "tool");
- expect(toolMsgs).toHaveLength(1);
- const toolContent = toolMsgs[0]?.content as Array<Record<string, unknown>>;
- expect(toolContent).toHaveLength(3);
- expect(toolContent.every((p) => p.type === "tool-result")).toBe(true);
- // IDs preserved per result.
- expect(toolContent.map((p) => p.toolCallId)).toEqual(["b1", "b2", "b3"]);
- });
-
- it("places cache breakpoints on [assistant, grouped-tool], not adjacent tool results (Root Cause 2)", async () => {
- // With grouping, the last two non-system messages of a mid-turn request
- // are [assistant(tool-calls), tool(all results)]. Both — plus the system
- // message — must carry an ephemeral cacheControl marker. The pre-fix bug
- // put both rolling breakpoints on two adjacent tool-result messages and
- // never marked the assistant turn.
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "c1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "c3", toolName: "read_file", input: { path: "c.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
- for await (const _ of agent.run("read three files")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
- }>;
-
- const isCached = (m?: {
- providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
- }) => m?.providerOptions?.anthropic?.cacheControl?.type === "ephemeral";
-
- // Exactly one tool message — no adjacent tool-result breakpoints.
- expect(messages.filter((m) => m.role === "tool")).toHaveLength(1);
-
- const systemMsg = messages.find((m) => m.role === "system");
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const toolMsg = messages.find((m) => m.role === "tool");
-
- expect(isCached(systemMsg)).toBe(true);
- expect(isCached(assistantMsg)).toBe(true);
- expect(isCached(toolMsg)).toBe(true);
- });
-
- it("does NOT attach cacheControl for the openai-compatible (non-Anthropic) path", async () => {
- // Sanity check: caching markers are Anthropic-only. The OpenAI-compatible
- // endpoints do automatic server-side prefix caching and reject explicit
- // cache_control, so no marker should be attached.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
- );
- const agent = new Agent(makeConfig()); // default → openai-compatible
- for await (const _ of agent.run("hello")) {
- /* consume */
- }
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { anthropic?: { cacheControl?: unknown } };
- }>;
- for (const m of messages) {
- expect(m.providerOptions?.anthropic?.cacheControl).toBeUndefined();
- }
- });
-
- // ─── Tool-call dedup (notes/tool-runner-duplication-incident.md) ─────────────────
-
- it("deduplicates byte-identical tool calls within a single batch", async () => {
- // Claude can degenerate and emit the same tool call (same name + args)
- // many times in one batch. Each copy keeps its own id (and still gets its
- // own result), but the tool must execute only ONCE — re-running identical
- // idempotent reads wastes time/money and floods the context.
- let execCount = 0;
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => {
- execCount++;
- return `contents of ${String(args.path)}`;
- },
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "d1",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- {
- type: "tool-call",
- toolCallId: "d2",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- {
- type: "tool-call",
- toolCallId: "d3",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- const events: AgentEvent[] = [];
- for await (const e of agent.run("read it thrice")) {
- events.push(e);
- }
-
- // Executed exactly once despite three identical calls.
- expect(execCount).toBe(1);
-
- // Every call id still received its own result, all with identical content.
- const results = events.filter(
- (e): e is Extract<AgentEvent, { type: "tool-result" }> => e.type === "tool-result",
- );
- expect(results).toHaveLength(3);
- expect(results.map((e) => e.toolResult.toolCallId).sort()).toEqual(["d1", "d2", "d3"]);
- for (const r of results) {
- expect(r.toolResult.result).toBe("contents of package.json");
- }
- });
-
- it("does NOT deduplicate tool calls with differing arguments", async () => {
- // Dedup is keyed on name + serialized arguments. Distinct args must each
- // execute — only byte-identical calls collapse.
- let execCount = 0;
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => {
- execCount++;
- return `contents of ${String(args.path)}`;
- },
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "e1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "e2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "e3", toolName: "read_file", input: { path: "a.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- for await (const _ of agent.run("read a, b, a")) {
- /* consume */
- }
-
- // a.txt + b.txt are distinct → two executions; the repeated a.txt reuses
- // the first result.
- expect(execCount).toBe(2);
- });
-
- // ─── Cache stability: per-step wire prefix is immutable ─────────────────────
-
- it("keeps earlier steps' wire messages byte-identical across requests (cache prefix is stable)", async () => {
- // A 3-step tool turn. The messages for steps 0 and 1 must serialize
- // identically in the step-2 request and the step-3 request — that
- // byte-stability is what lets Anthropic's rolling prompt cache extend
- // instead of re-writing the whole prefix every step (notes/cache-miss-report.md).
- // Uses the openai-compatible provider so no cacheControl markers (which
- // intentionally move each step) obscure the content comparison.
- let n = 0;
- // mock.calls accumulates across tests in this file — reset so our
- // `calls.length` assertions count only this run's requests.
- vi.mocked(streamText).mockClear();
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- const toolStep = (id: string, path: string) =>
- makeMockStreamResult([
- { type: "reasoning-delta", id: `r${id}`, text: `thinking ${id}` },
- { type: "text-delta", id: `t${id}`, text: `step ${id}` },
- { type: "tool-call", toolCallId: id, toolName: "read_file", input: { path } },
- finishToolCalls,
- ]);
- vi.mocked(streamText).mockImplementation(() => {
- n++;
- if (n === 1) return toolStep("s0", "a.txt");
- if (n === 2) return toolStep("s1", "b.txt");
- if (n === 3) return toolStep("s2", "c.txt");
- return makeMockStreamResult([{ type: "text-delta", id: "tf", text: "done" }, finishStop]);
- });
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- for await (const _ of agent.run("go")) {
- /* consume */
- }
-
- // 4 streamText calls (steps 0..3). Compare the step-2 request (call idx 2)
- // and step-3 request (call idx 3).
- const calls = vi.mocked(streamText).mock.calls;
- expect(calls.length).toBe(4);
- const req2 = calls[2]?.[0]?.messages as unknown[];
- const req3 = calls[3]?.[0]?.messages as unknown[];
-
- // Step-2 request = [system, user, a(s0), tool(s0), a(s1), tool(s1)] (6).
- // Step-3 request appends a(s2), tool(s2). The shared 6-message prefix
- // must be byte-identical.
- expect(req2).toHaveLength(6);
- expect(req3).toHaveLength(8);
- expect(JSON.stringify(req3.slice(0, 6))).toBe(JSON.stringify(req2));
-
- // And each step really is its own [assistant, tool] pair (not one merged
- // assistant message with all tool calls bunched together).
- const roles = (req3 as Array<{ role: string }>).map((m) => m.role);
- expect(roles).toEqual([
- "system",
- "user",
- "assistant",
- "tool",
- "assistant",
- "tool",
- "assistant",
- "tool",
- ]);
- });
-
- // ─── Usage / cache-rate telemetry ──────────────────────────────────────────
-
- it("emits a usage event from the finish-step part with the cache read/write split", async () => {
- // The per-step `usage` (with Anthropic's cache read/write split in
- // `inputTokenDetails`) rides on the `finish-step` part — NOT the terminal
- // `finish` part, which only carries the aggregate `totalUsage`. The agent
- // re-emits it as a `usage` AgentEvent that powers the Cache Rate view.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "hi" },
- {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- usage: {
- inputTokens: 1000,
- outputTokens: 50,
- inputTokenDetails: {
- noCacheTokens: 200,
- cacheReadTokens: 750,
- cacheWriteTokens: 50,
- },
- },
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const e of agent.run("hi")) {
- events.push(e);
- }
-
- const usageEvents = events.filter(
- (e): e is Extract<AgentEvent, { type: "usage" }> => e.type === "usage",
- );
- // Exactly one usage event (from finish-step) — the terminal `finish`
- // part must NOT double-count.
- expect(usageEvents).toHaveLength(1);
- expect(usageEvents[0]?.usage).toEqual({
- inputTokens: 1000,
- outputTokens: 50,
- cacheReadTokens: 750,
- cacheWriteTokens: 50,
- });
- });
-
- it("does NOT emit a usage event when no finish-step usage is present", async () => {
- // `finishStop` (type `finish`, aggregate `totalUsage` only) must not
- // trigger a usage event — and with no `finish-step` part there is no
- // per-step usage to emit.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const e of agent.run("hi")) {
- events.push(e);
- }
-
- expect(events.some((e) => e.type === "usage")).toBe(false);
- });
-});
-
-describe("anthropicThinkingProviderOptions — adaptive-thinking model detection", () => {
- // Pure function: no provider construction, no streamText, no network I/O.
- // Mirrors opencode's transform.ts detection — Opus 4.7+ AND Opus/Sonnet 4.6
- // are adaptive; only Opus 4.7+ needs display:"summarized" to surface thinking.
-
- it("Opus 4.8 → adaptive + display:summarized (the reported bug)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-8", "max")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "max",
- });
- });
-
- it("Opus 4.7 → adaptive + display:summarized (dash and dot id forms)", () => {
- const expected = { thinking: { type: "adaptive", display: "summarized" }, effort: "high" };
- expect(anthropicThinkingProviderOptions("claude-opus-4-7", "high")).toEqual(expected);
- expect(anthropicThinkingProviderOptions("claude-opus-4.7", "high")).toEqual(expected);
- });
-
- it("Sonnet 4.6 → adaptive WITHOUT display (dash and dot id forms)", () => {
- const expected = { thinking: { type: "adaptive" }, effort: "medium" };
- expect(anthropicThinkingProviderOptions("claude-sonnet-4-6", "medium")).toEqual(expected);
- expect(anthropicThinkingProviderOptions("claude-sonnet-4.6", "medium")).toEqual(expected);
- });
-
- it("Opus 4.6 → adaptive WITHOUT display", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-6", "high")).toEqual({
- thinking: { type: "adaptive" },
- effort: "high",
- });
- });
-
- it("older Claude (Opus 4.5, dated Sonnet) → classic enabled thinking", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-5", "max")).toEqual({
- thinking: { type: "enabled", budgetTokens: 31999 },
- });
- expect(anthropicThinkingProviderOptions("claude-sonnet-4-20250514", "high")).toEqual({
- thinking: { type: "enabled", budgetTokens: 16000 },
- });
- });
-
- it("uses a version parse, not a hardcoded string (future Opus 4.9 is adaptive)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-9", "high")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "high",
- });
- });
-
- it("maps reasoning effort → budgetTokens for enabled (non-adaptive) models", () => {
- const budget = (e: "low" | "medium" | "high" | "xhigh" | "max") => {
- const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
- thinking: { type: "enabled"; budgetTokens: number };
- };
- return opts.thinking.budgetTokens;
- };
- expect(budget("low")).toBe(2000);
- expect(budget("medium")).toBe(5000);
- expect(budget("high")).toBe(16000);
- expect(budget("xhigh")).toBe(24000);
- expect(budget("max")).toBe(31999);
- });
-
- it("xhigh budget sits strictly between high and max (ordering invariant)", () => {
- const budget = (e: "high" | "xhigh" | "max") => {
- const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
- thinking: { type: "enabled"; budgetTokens: number };
- };
- return opts.thinking.budgetTokens;
- };
- expect(budget("high")).toBeLessThan(budget("xhigh"));
- expect(budget("xhigh")).toBeLessThan(budget("max"));
- });
-
- it("forwards xhigh verbatim as the adaptive effort sibling (Opus 4.7+)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-8", "xhigh")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "xhigh",
- });
- });
-
- describe("multimodal user content", () => {
- it("emits ordered text + image parts to the model when content is provided", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("here is image A: [image]", {
- content: [
- { type: "text", text: "here is image A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- expect(userMsg).toBeDefined();
- // Multimodal turn → content is an ordered parts array, not a string.
- expect(Array.isArray(userMsg?.content)).toBe(true);
- const parts = userMsg?.content as Array<Record<string, unknown>>;
- expect(parts[0]).toMatchObject({ type: "text", text: "here is image A: " });
- expect(parts[1]).toMatchObject({ type: "image", mediaType: "image/png" });
- expect(String(parts[1]?.image)).toBe("data:image/png;base64,QQ==");
- });
-
- it("emits a FilePart for a PDF attachment with its filename", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("see [pdf]", {
- content: [
- { type: "text", text: "see " },
- { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
- ],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- const parts = userMsg?.content as Array<Record<string, unknown>>;
- const filePart = parts.find((p) => p.type === "file");
- expect(filePart).toMatchObject({
- type: "file",
- mediaType: "application/pdf",
- filename: "doc.pdf",
- });
- expect(String(filePart?.data)).toBe("data:application/pdf;base64,QQ==");
- });
-
- it("persists the user turn as text only (no content) for history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("look: [image]", {
- content: [
- { type: "text", text: "look: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- })) {
- // consume
- }
-
- // The in-memory user message keeps the text chunk for the render/persist
- // path; the ephemeral `content` rides alongside it but isn't a chunk.
- const userMsg = agent.messages.find((m) => m.role === "user");
- expect(userMsg?.chunks).toEqual([{ type: "text", text: "look: [image]" }]);
- });
-
- it("falls back to a plain string when content has no attachment", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("plain text", {
- content: [{ type: "text", text: "plain text" }],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- // No attachment → plain string content (byte-identical to text-only path).
- expect(typeof userMsg?.content).toBe("string");
- expect(userMsg?.content).toBe("plain text");
- });
- });
-
- describe("warmCache (prompt-cache warming replay)", () => {
- function makeWarmStream(usage: {
- inputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- }) {
- return makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "." },
- {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- usage: {
- inputTokens: usage.inputTokens,
- outputTokens: 1,
- inputTokenDetails: {
- noCacheTokens: usage.inputTokens - usage.cacheReadTokens - usage.cacheWriteTokens,
- cacheReadTokens: usage.cacheReadTokens,
- cacheWriteTokens: usage.cacheWriteTokens,
- },
- },
- },
- finishStop,
- ]);
- }
-
- const history = [
- { role: "user" as const, chunks: [{ type: "text" as const, text: "hello" }] },
- { role: "assistant" as const, chunks: [{ type: "text" as const, text: "hi there" }] },
- ];
-
- it("returns the request usage (cache read/write split) without throwing", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 1000, cacheReadTokens: 950, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- const usage = await agent.warmCache(history);
- expect(usage).toEqual({
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 950,
- cacheWriteTokens: 0,
- });
- });
-
- it("appends a single trivial throwaway user turn at the END of the history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await agent.warmCache(history);
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- // system + 2 history messages + 1 throwaway user turn.
- expect(messages[0]?.role).toBe("system");
- const last = messages.at(-1);
- expect(last?.role).toBe("user");
- // The throwaway turn's text must be the trivial probe.
- const lastText = JSON.stringify(last?.content);
- expect(lastText).toContain("reply with just a .");
- // Exactly one extra user turn beyond the genuine history's single user msg.
- const userMsgs = messages.filter((m) => m.role === "user");
- expect(userMsgs).toHaveLength(2);
- });
-
- it("sends Anthropic cache_control breakpoints with the SAME toolChoice/thinking as a real turn", async () => {
- // Anthropic keys the MESSAGE cache on `tool_choice` AND the extended-
- // thinking parameters. If warming sent a different value than a real
- // turn, it would warm a DIFFERENT message-cache bucket and the user's
- // next real message would still miss. So warming MUST mirror run():
- // toolChoice "auto" + the thinking providerOptions for the effort.
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await agent.warmCache(history);
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs?.toolChoice).toBe("auto");
- // Thinking providerOptions present (effort defaults to "max").
- expect(callArgs?.providerOptions?.anthropic).toBeDefined();
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { anthropic?: { cacheControl?: unknown } };
- }>;
- const hasBreakpoint = messages.some(
- (m) => m.providerOptions?.anthropic?.cacheControl !== undefined,
- );
- expect(hasBreakpoint).toBe(true);
- });
-
- it("warming and a real turn send IDENTICAL cache-affecting params (same bucket)", async () => {
- // The core invariant of the whole feature: warmCache() and run() must
- // produce the same toolChoice + thinking providerOptions + maxOutputTokens
- // so the warming replay refreshes the EXACT cache the next real message
- // reads. Drive both and compare the cache-key inputs streamText receives.
- const cfg = makeConfig({ provider: "anthropic" });
-
- // 1) Real turn for the same history + the probe text as the user msg.
- const realAgent = new Agent(cfg);
- realAgent.messages.push(...history.map((m) => ({ ...m })));
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "." }, finishStop]),
- );
- for await (const _ of realAgent.run("reply with just a .")) {
- // consume
- }
- const realArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
-
- // 2) Warming replay for the same history.
- const warmAgent = new Agent(cfg);
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- await warmAgent.warmCache(history);
- const warmArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
-
- // The cache-affecting parameters must be byte-identical.
- expect(warmArgs?.toolChoice).toEqual(realArgs?.toolChoice);
- expect(warmArgs?.maxOutputTokens).toEqual(realArgs?.maxOutputTokens);
- expect(warmArgs?.providerOptions).toEqual(realArgs?.providerOptions);
- });
-
- it("does NOT mutate the agent's own message history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- expect(agent.messages).toHaveLength(0);
- await agent.warmCache(history);
- // warmCache takes history as an argument and never touches `this.messages`.
- expect(agent.messages).toHaveLength(0);
- // And it must not have flipped the agent into a running state.
- expect(agent.status).toBe("idle");
- });
-
- it("throws a formatted error when the stream errors", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "error", error: new Error("boom") }]),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await expect(agent.warmCache(history)).rejects.toThrow(/boom/);
- });
- });
-});
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
deleted file mode 100644
index a223a4f..0000000
--- a/packages/core/tests/agents/loader.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- expandAgentToolNames,
- getAgentDirPaths,
- loadAgent,
- saveAgent,
-} from "../../src/agents/loader.js";
-
-describe("expandAgentToolNames", () => {
- it("expands 'read' into the granular read tools", () => {
- const out = expandAgentToolNames(["read"]);
- expect(out).toContain("read_file");
- expect(out).toContain("read_file_slice");
- expect(out).toContain("list_files");
- });
-
- it("expands 'edit' into write_file", () => {
- const out = expandAgentToolNames(["edit"]);
- expect(out).toContain("write_file");
- });
-
- it("expands 'bash' into run_shell", () => {
- const out = expandAgentToolNames(["bash"]);
- expect(out).toContain("run_shell");
- });
-
- it("passes through the tab tools as independent names (no tab_comm group)", () => {
- const out = expandAgentToolNames(["send_to_tab", "read_tab"]);
- expect(out).toContain("send_to_tab");
- expect(out).toContain("read_tab");
- // Granting only one must not pull in the other.
- const onlySend = expandAgentToolNames(["send_to_tab"]);
- expect(onlySend).toContain("send_to_tab");
- expect(onlySend).not.toContain("read_tab");
- });
-
- it("passes through non-group tool names unchanged", () => {
- const out = expandAgentToolNames([
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "search_code",
- "send_to_tab",
- "read_tab",
- ]);
- expect(out).toEqual(
- expect.arrayContaining([
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "search_code",
- "send_to_tab",
- "read_tab",
- ]),
- );
- });
-
- it("always includes 'todo' even when not requested", () => {
- expect(expandAgentToolNames([])).toContain("todo");
- expect(expandAgentToolNames(["read"])).toContain("todo");
- expect(expandAgentToolNames(["summon"])).toContain("todo");
- });
-
- it("deduplicates when groups overlap with explicit names", () => {
- const out = expandAgentToolNames(["read", "read_file"]);
- // Each name should appear at most once
- const counts = new Map<string, number>();
- for (const t of out) counts.set(t, (counts.get(t) ?? 0) + 1);
- for (const [, c] of counts) expect(c).toBe(1);
- });
-});
-
-describe("getAgentDirPaths", () => {
- it("returns just the global dir when no projectDir is supplied", () => {
- const paths = getAgentDirPaths();
- expect(paths).toHaveLength(1);
- expect(paths[0]).toContain(".config/dispatch/agents");
- });
-
- it("appends the project-scoped dir when projectDir is supplied", () => {
- const paths = getAgentDirPaths("/some/project");
- expect(paths).toHaveLength(2);
- expect(paths[1]).toBe("/some/project/.dispatch/agents");
- });
-});
-
-describe("loadAgent — project-scoped sandbox", () => {
- // `GLOBAL_AGENTS_DIR` is captured at module load via `os.homedir()`
- // and can't be redirected at runtime. The project-scoped path,
- // however, is computed per-call from the `projectDir` argument, so
- // we exercise that branch instead. This is also the more common
- // real-world case (per-project agent definitions).
- let tmpProject: string;
-
- beforeEach(() => {
- tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-loader-test-"));
- });
-
- afterEach(() => {
- fs.rmSync(tmpProject, { recursive: true, force: true });
- });
-
- function writeAgentToml(slug: string, body: string): void {
- const agentsDir = path.join(tmpProject, ".dispatch", "agents");
- fs.mkdirSync(agentsDir, { recursive: true });
- fs.writeFileSync(path.join(agentsDir, `${slug}.toml`), body, "utf-8");
- }
-
- // Uses a slug unlikely to collide with anything the user might
- // already have in ~/.config/dispatch/agents. `loadAgent` returns
- // the FIRST match it finds across all scanned directories, and
- // the global scope is scanned before the project scope — a slug
- // that exists in both would resolve to the global one (which is
- // real, not under our control). The "z-dispatch-test-*" prefix
- // gives this fixture exclusive ownership of the slug.
- const TEST_SLUG = "z-dispatch-test-fixture";
-
- it("returns null for an unknown slug within the project scope", () => {
- const agent = loadAgent("z-dispatch-test-does-not-exist", tmpProject);
- expect(agent).toBeNull();
- });
-
- it("loads a TOML definition written to the project's .dispatch/agents", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- 'description = "Sandbox fixture for loadAgent test."',
- "skills = []",
- 'tools = ["read", "bash"]',
- "is_subagent = true",
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent).not.toBeNull();
- expect(agent?.slug).toBe(TEST_SLUG);
- expect(agent?.name).toBe("Fixture");
- expect(agent?.tools).toEqual(["read", "bash"]);
- expect(agent?.is_subagent).toBe(true);
- expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
- expect(agent?.scope).toBe(tmpProject);
- });
-
- it("parses a per-model effort when it is a recognised level", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- "skills = []",
- 'tools = ["read"]',
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- 'effort = "low"',
- "",
- "[[models]]",
- 'key_id = "claude-max"',
- 'model_id = "claude-opus-4-8"',
- 'effort = "xhigh"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "low" },
- { key_id: "claude-max", model_id: "claude-opus-4-8", effort: "xhigh" },
- ]);
- });
-
- it("drops an invalid effort value so the call site falls back to the default", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- "skills = []",
- 'tools = ["read"]',
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- 'effort = "turbo"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
- expect(agent?.models[0]).not.toHaveProperty("effort");
- });
-
- it("round-trips effort through saveAgent → loadAgent", () => {
- saveAgent({
- name: "Fixture",
- description: "",
- skills: [],
- tools: ["read"],
- models: [
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
- { key_id: "claude-max", model_id: "claude-opus-4-8" },
- ],
- scope: tmpProject,
- slug: TEST_SLUG,
- });
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
- { key_id: "claude-max", model_id: "claude-opus-4-8" },
- ]);
- });
-
- it("sanitizes the slug so path traversal can't reach outside the agents dir", () => {
- // Even if a caller passes something gnarly, the lookup is by
- // sanitized slug — no file outside the configured dirs should
- // ever be opened. The sanitized form ("etc-passwd") obviously
- // doesn't exist in the temp project, so the result is null.
- const agent = loadAgent("../../../etc/passwd", tmpProject);
- expect(agent).toBeNull();
- });
-});
diff --git a/packages/core/tests/chunks/append.test.ts b/packages/core/tests/chunks/append.test.ts
deleted file mode 100644
index dc277d2..0000000
--- a/packages/core/tests/chunks/append.test.ts
+++ /dev/null
@@ -1,534 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { appendEventToChunks, applySystemEvent } from "../../src/chunks/append.js";
-import type { AgentEvent, ChatMessage, Chunk } from "../../src/types/index.js";
-
-// ─── helpers ─────────────────────────────────────────────────────
-
-const td = (delta: string): AgentEvent => ({ type: "text-delta", delta });
-const rd = (delta: string): AgentEvent => ({ type: "reasoning-delta", delta });
-const re = (metadata?: Record<string, unknown>): AgentEvent => ({
- type: "reasoning-end",
- ...(metadata !== undefined ? { metadata } : {}),
-});
-const tc = (id: string, name = "fake_tool", args: Record<string, unknown> = {}): AgentEvent => ({
- type: "tool-call",
- toolCall: { id, name, arguments: args },
-});
-const tr = (toolCallId: string, result: string, isError = false): AgentEvent => ({
- type: "tool-result",
- toolResult: { toolCallId, toolName: "fake_tool", result, isError },
-});
-const so = (data: string, stream: "stdout" | "stderr" = "stdout"): AgentEvent => ({
- type: "shell-output",
- data,
- stream,
-});
-const err = (error: string, statusCode?: number): AgentEvent => ({
- type: "error",
- error,
- ...(statusCode !== undefined ? { statusCode } : {}),
-});
-const notice = (message: string): AgentEvent => ({ type: "notice", message });
-const modelChanged = (keyId: string, modelId: string): AgentEvent => ({
- type: "model-changed",
- keyId,
- modelId,
-});
-const configReload: AgentEvent = { type: "config-reload" };
-
-function run(events: AgentEvent[]): Chunk[] {
- const chunks: Chunk[] = [];
- for (const e of events) appendEventToChunks(chunks, e);
- return chunks;
-}
-
-// ─── Required cases from the plan ────────────────────────────────
-
-describe("appendEventToChunks — required cases from plan", () => {
- it("empty chunks + text-delta → one text chunk with the delta", () => {
- const chunks = run([td("Hello")]);
- expect(chunks).toEqual([{ type: "text", text: "Hello" }]);
- });
-
- it("two consecutive text-deltas → one text chunk with concatenated text", () => {
- const chunks = run([td("Hello, "), td("world!")]);
- expect(chunks).toEqual([{ type: "text", text: "Hello, world!" }]);
- });
-
- it("text-delta then reasoning-delta → two chunks (text, thinking)", () => {
- const chunks = run([td("ans: 42"), rd("I should explain")]);
- expect(chunks).toEqual([
- { type: "text", text: "ans: 42" },
- { type: "thinking", text: "I should explain" },
- ]);
- });
-
- it("text-delta then tool-call → two chunks (text, tool-batch with one entry)", () => {
- const chunks = run([td("Looking..."), tc("t1", "read_file", { path: "x" })]);
- expect(chunks).toEqual([
- { type: "text", text: "Looking..." },
- {
- type: "tool-batch",
- calls: [{ id: "t1", name: "read_file", arguments: { path: "x" } }],
- },
- ]);
- });
-
- it("two consecutive tool-calls → one tool-batch with two entries", () => {
- const chunks = run([tc("t1", "read_file"), tc("t2", "list_files")]);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]).toMatchObject({
- type: "tool-batch",
- calls: [
- { id: "t1", name: "read_file" },
- { id: "t2", name: "list_files" },
- ],
- });
- });
-
- it("tool-call then tool-call then text → two chunks (tool-batch with 2 entries, text)", () => {
- const chunks = run([tc("t1"), tc("t2"), td("done")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]).toMatchObject({
- type: "tool-batch",
- calls: [{ id: "t1" }, { id: "t2" }],
- });
- expect(chunks[1]).toEqual({ type: "text", text: "done" });
- });
-
- it("tool-result arrives → updates matching tool-call entry in the latest tool-batch chunk by id", () => {
- const chunks = run([
- tc("t1"),
- tc("t2"),
- tr("t1", "first-result"),
- tr("t2", "second-result", true),
- ]);
- expect(chunks).toHaveLength(1);
- const batch = chunks[0];
- expect(batch.type).toBe("tool-batch");
- if (batch.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]).toMatchObject({ id: "t1", result: "first-result", isError: false });
- expect(batch.calls[1]).toMatchObject({ id: "t2", result: "second-result", isError: true });
- });
-
- it("shell-output arrives → appends to the most recent tool-call's shellOutput", () => {
- const chunks = run([
- tc("t1", "run_shell"),
- so("hello\n", "stdout"),
- so("world\n", "stdout"),
- so("err!\n", "stderr"),
- ]);
- expect(chunks).toHaveLength(1);
- const batch = chunks[0];
- if (batch.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]?.shellOutput).toEqual({
- stdout: "hello\nworld\n",
- stderr: "err!\n",
- });
- });
-
- it("error event → opens an error chunk; subsequent events go to new chunks", () => {
- const chunks = run([td("partial..."), err("network failed", 503), td("recovery")]);
- expect(chunks).toEqual([
- { type: "text", text: "partial..." },
- { type: "error", message: "network failed", statusCode: 503 },
- { type: "text", text: "recovery" },
- ]);
- });
-
- it("system event during text run → closes text, opens system, would re-open text on next text-delta", () => {
- const chunks = run([td("first "), notice("model swap"), td("second")]);
- expect(chunks).toEqual([
- { type: "text", text: "first " },
- { type: "system", kind: "notice", text: "model swap" },
- { type: "text", text: "second" },
- ]);
- });
-
- it("two consecutive system events → two separate system chunks (no coalescing)", () => {
- const chunks = run([notice("a"), notice("b")]);
- expect(chunks).toEqual([
- { type: "system", kind: "notice", text: "a" },
- { type: "system", kind: "notice", text: "b" },
- ]);
- });
-
- it("interleaved think → text → think → tool → think → text → 6 chunks in order", () => {
- const chunks = run([
- rd("planning..."),
- td("here goes:"),
- rd("hmm, actually"),
- tc("t1", "read_file"),
- rd("ok now"),
- td("and so..."),
- ]);
- expect(chunks.map((c) => c.type)).toEqual([
- "thinking",
- "text",
- "thinking",
- "tool-batch",
- "thinking",
- "text",
- ]);
- expect(chunks[0]).toEqual({ type: "thinking", text: "planning..." });
- expect(chunks[1]).toEqual({ type: "text", text: "here goes:" });
- expect(chunks[2]).toEqual({ type: "thinking", text: "hmm, actually" });
- expect(chunks[3]).toMatchObject({ type: "tool-batch", calls: [{ id: "t1" }] });
- expect(chunks[4]).toEqual({ type: "thinking", text: "ok now" });
- expect(chunks[5]).toEqual({ type: "text", text: "and so..." });
- });
-});
-
-// ─── Additional transition coverage ──────────────────────────────
-
-describe("appendEventToChunks — transition matrix", () => {
- it("thinking → thinking coalesces", () => {
- const chunks = run([rd("a"), rd("b")]);
- expect(chunks).toEqual([{ type: "thinking", text: "ab" }]);
- });
-
- it("thinking → text opens a new text chunk", () => {
- const chunks = run([rd("think"), td("speak")]);
- expect(chunks).toEqual([
- { type: "thinking", text: "think" },
- { type: "text", text: "speak" },
- ]);
- });
-
- it("tool-batch → text opens a new text chunk", () => {
- const chunks = run([tc("t1"), td("after tool")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[1]).toEqual({ type: "text", text: "after tool" });
- });
-
- it("text → reasoning-delta after a multi-delta text run still splits cleanly", () => {
- const chunks = run([td("a"), td("b"), rd("x"), rd("y"), td("c")]);
- expect(chunks).toEqual([
- { type: "text", text: "ab" },
- { type: "thinking", text: "xy" },
- { type: "text", text: "c" },
- ]);
- });
-
- it("error → text opens a fresh text chunk after the error", () => {
- const chunks = run([err("boom"), td("recovered")]);
- expect(chunks).toEqual([
- { type: "error", message: "boom" },
- { type: "text", text: "recovered" },
- ]);
- });
-
- it("two consecutive errors stay as two error chunks (no coalescing)", () => {
- const chunks = run([err("first"), err("second", 429)]);
- expect(chunks).toEqual([
- { type: "error", message: "first" },
- { type: "error", message: "second", statusCode: 429 },
- ]);
- });
-
- it("system → tool-call opens a new tool-batch (does not extend the system chunk)", () => {
- const chunks = run([notice("info"), tc("t1")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[1]).toMatchObject({ type: "tool-batch", calls: [{ id: "t1" }] });
- });
-
- it("tool-result with no matching call is silently dropped", () => {
- const chunks = run([td("hi"), tr("no-such-id", "ignored")]);
- expect(chunks).toEqual([{ type: "text", text: "hi" }]);
- });
-
- it("shell-output with no tool-batch in scope is silently dropped", () => {
- const chunks = run([td("hi"), so("orphan")]);
- expect(chunks).toEqual([{ type: "text", text: "hi" }]);
- });
-
- it("tool-result for an earlier batch still updates the right call (results can arrive late)", () => {
- // Order: tc -> td -> tc(new batch) -> tr(for first batch's id)
- const chunks = run([
- tc("t1", "read_file"),
- td("midstream text"),
- tc("t2", "list_files"),
- tr("t1", "late result for first"),
- ]);
- // Two tool-batches, separated by the text chunk. The result must land
- // inside the FIRST batch (the one containing t1), not the most-recent.
- expect(chunks.map((c) => c.type)).toEqual(["tool-batch", "text", "tool-batch"]);
- const first = chunks[0];
- if (first?.type !== "tool-batch") throw new Error("type guard");
- expect(first.calls[0]).toMatchObject({ id: "t1", result: "late result for first" });
- const second = chunks[2];
- if (second?.type !== "tool-batch") throw new Error("type guard");
- // t2 in the second batch has no result yet.
- expect(second.calls[0]?.result).toBeUndefined();
- });
-
- it("shell-output goes to the most recent tool-batch's most recent entry, even with intervening chunks", () => {
- // First batch's tool runs, emits output, then later a second batch starts and emits output.
- const chunks = run([
- tc("t1", "run_shell"),
- so("first-stdout\n"),
- td("interlude"),
- tc("t2", "run_shell"),
- so("second-stdout\n"),
- ]);
- expect(chunks.map((c) => c.type)).toEqual(["tool-batch", "text", "tool-batch"]);
- const first = chunks[0];
- const second = chunks[2];
- if (first?.type !== "tool-batch" || second?.type !== "tool-batch") {
- throw new Error("type guard");
- }
- expect(first.calls[0]?.shellOutput).toEqual({ stdout: "first-stdout\n", stderr: "" });
- expect(second.calls[0]?.shellOutput).toEqual({ stdout: "second-stdout\n", stderr: "" });
- });
-
- it("model-changed event opens a system chunk with kind=model-changed", () => {
- const chunks = run([modelChanged("anthropic-1", "claude-sonnet-4")]);
- expect(chunks).toEqual([
- {
- type: "system",
- kind: "model-changed",
- text: "Switched to claude-sonnet-4 (anthropic-1)",
- },
- ]);
- });
-
- it("config-reload event opens a system chunk with kind=config-reload", () => {
- const chunks = run([configReload]);
- expect(chunks).toEqual([
- { type: "system", kind: "config-reload", text: "Configuration reloaded" },
- ]);
- });
-
- // ─── reasoning-end (v6 SDK metadata round-trip) ──────────────────
-
- it("reasoning-delta then reasoning-end seals the thinking chunk with metadata", () => {
- const meta = { anthropic: { signature: "sig-1" } };
- const chunks = run([rd("plan"), re(meta)]);
- expect(chunks).toEqual([{ type: "thinking", text: "plan", metadata: meta }]);
- });
-
- it("two reasoning-deltas then reasoning-end coalesces text and seals once", () => {
- const meta = { anthropic: { signature: "abc" } };
- const chunks = run([rd("a"), rd("b"), re(meta)]);
- expect(chunks).toEqual([{ type: "thinking", text: "ab", metadata: meta }]);
- });
-
- it("reasoning-delta → reasoning-end → reasoning-delta opens a NEW chunk", () => {
- // Each Anthropic thinking content block gets its own metadata.
- // Extending a sealed chunk would corrupt the text/metadata mapping.
- const meta1 = { anthropic: { signature: "sig-1" } };
- const chunks = run([rd("first"), re(meta1), rd("second")]);
- expect(chunks).toEqual([
- { type: "thinking", text: "first", metadata: meta1 },
- { type: "thinking", text: "second" },
- ]);
- });
-
- it("rd → re → rd → re produces two independently sealed chunks", () => {
- const m1 = { anthropic: { signature: "s1" } };
- const m2 = { anthropic: { signature: "s2" } };
- const chunks = run([rd("first"), re(m1), rd("second"), re(m2)]);
- expect(chunks).toEqual([
- { type: "thinking", text: "first", metadata: m1 },
- { type: "thinking", text: "second", metadata: m2 },
- ]);
- });
-
- it("orphan reasoning-end (no prior thinking chunk) is a no-op", () => {
- const chunks = run([re({ anthropic: { signature: "orphan" } })]);
- expect(chunks).toEqual([]);
- });
-
- it("reasoning-end after an already-sealed thinking chunk does NOT overwrite", () => {
- const m1 = { anthropic: { signature: "first" } };
- const m2 = { anthropic: { signature: "second" } };
- const chunks = run([rd("a"), re(m1), re(m2)]);
- expect(chunks).toEqual([{ type: "thinking", text: "a", metadata: m1 }]);
- });
-
- it("reasoning-end without metadata is a silent no-op (does not seal)", () => {
- // v6 may emit reasoning-end with no providerMetadata for
- // non-Anthropic providers. Don't seal those chunks — a subsequent
- // reasoning-delta should continue extending.
- const chunks = run([rd("hello"), re(), rd(" world")]);
- expect(chunks).toEqual([{ type: "thinking", text: "hello world" }]);
- });
-
- it("re walks back across an intervening text chunk to seal the right thinking", () => {
- // Defensive: even if a non-thinking chunk lands between the
- // reasoning text and its end-event, the metadata still attaches
- // to the unsealed thinking chunk.
- const meta = { anthropic: { signature: "late" } };
- const chunks = run([rd("plan"), td("midstream"), re(meta)]);
- expect(chunks).toEqual([
- { type: "thinking", text: "plan", metadata: meta },
- { type: "text", text: "midstream" },
- ]);
- });
-
- it("interleaved rd / re / tool-call / rd / re produces correct chunk sequence", () => {
- // Anthropic's interleaved-thinking emits a thinking block, then
- // a tool call, then another thinking block. Each thinking block
- // gets its own metadata.
- const m1 = { anthropic: { signature: "before-tool" } };
- const m2 = { anthropic: { signature: "after-tool" } };
- const chunks = run([
- rd("plan tool"),
- re(m1),
- tc("t1", "read_file"),
- rd("plan response"),
- re(m2),
- ]);
- expect(chunks.map((c) => c.type)).toEqual(["thinking", "tool-batch", "thinking"]);
- expect(chunks[0]).toEqual({
- type: "thinking",
- text: "plan tool",
- metadata: m1,
- });
- expect(chunks[2]).toEqual({
- type: "thinking",
- text: "plan response",
- metadata: m2,
- });
- });
-
- it("non-content events (status / done / task-list-update / message-queued etc.) are no-ops", () => {
- const chunks = run([
- td("hello"),
- { type: "status", status: "running" },
- { type: "task-list-update", tasks: [] },
- {
- type: "tab-created",
- id: "tab1",
- title: "x",
- keyId: null,
- modelId: null,
- parentTabId: null,
- workingDirectory: null,
- },
- { type: "message-queued", tabId: "t", messageId: "m", message: "queued" },
- { type: "message-consumed", tabId: "t", messageIds: ["m"] },
- { type: "message-cancelled", tabId: "t", messageId: "m" },
- {
- type: "done",
- message: { role: "assistant", chunks: [] },
- },
- td(" world"),
- ]);
- expect(chunks).toEqual([{ type: "text", text: "hello world" }]);
- });
-
- it("error chunk omits statusCode when not provided", () => {
- const chunks = run([err("boom")]);
- expect(chunks).toEqual([{ type: "error", message: "boom" }]);
- // And no stray statusCode key:
- expect(Object.hasOwn(chunks[0], "statusCode")).toBe(false);
- });
-
- it("tool-result updates isError=false correctly (default success path)", () => {
- const chunks = run([tc("t1"), tr("t1", "ok", false)]);
- const batch = chunks[0];
- if (batch?.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]).toMatchObject({ result: "ok", isError: false });
- });
-});
-
-// ─── applySystemEvent routing ────────────────────────────────────
-
-describe("applySystemEvent", () => {
- type Msg = { id: string; role: "user" | "assistant" | "system"; chunks: Chunk[] };
-
- let counter = 0;
- const idFactory = () => `gen-${++counter}`;
-
- it("creates a new role:system message when message list is empty", () => {
- counter = 0;
- const messages: Msg[] = [];
- const result = applySystemEvent(messages, { kind: "notice", text: "hello" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages).toEqual([
- {
- id: "gen-1",
- role: "system",
- chunks: [{ type: "system", kind: "notice", text: "hello" }],
- },
- ]);
- });
-
- it("creates a new role:system message when last message is user", () => {
- counter = 0;
- const messages: Msg[] = [{ id: "u1", role: "user", chunks: [{ type: "text", text: "hi" }] }];
- const result = applySystemEvent(messages, { kind: "model-changed", text: "swap" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages).toHaveLength(2);
- expect(messages[1]).toMatchObject({
- id: "gen-1",
- role: "system",
- chunks: [{ type: "system", kind: "model-changed", text: "swap" }],
- });
- });
-
- it("creates a new role:system message when last message is assistant", () => {
- counter = 0;
- const messages: Msg[] = [
- { id: "a1", role: "assistant", chunks: [{ type: "text", text: "done" }] },
- ];
- applySystemEvent(messages, { kind: "config-reload", text: "reloaded" }, idFactory);
- expect(messages).toHaveLength(2);
- expect(messages[1]?.role).toBe("system");
- });
-
- it("appends a chunk to the existing system message when last message is role:system", () => {
- counter = 0;
- const messages: Msg[] = [
- {
- id: "s1",
- role: "system",
- chunks: [{ type: "system", kind: "notice", text: "first" }],
- },
- ];
- const result = applySystemEvent(messages, { kind: "notice", text: "second" }, idFactory);
- expect(result.messageId).toBe("s1");
- expect(messages).toHaveLength(1);
- expect(messages[0]?.chunks).toEqual([
- { type: "system", kind: "notice", text: "first" },
- { type: "system", kind: "notice", text: "second" },
- ]);
- });
-
- it("multiple consecutive calls accumulate in the same system message", () => {
- counter = 0;
- const messages: Msg[] = [{ id: "u1", role: "user", chunks: [{ type: "text", text: "hi" }] }];
- applySystemEvent(messages, { kind: "notice", text: "a" }, idFactory);
- applySystemEvent(messages, { kind: "notice", text: "b" }, idFactory);
- applySystemEvent(messages, { kind: "model-changed", text: "c" }, idFactory);
- expect(messages).toHaveLength(2);
- const sys = messages[1];
- expect(sys?.role).toBe("system");
- expect(sys?.chunks).toEqual([
- { type: "system", kind: "notice", text: "a" },
- { type: "system", kind: "notice", text: "b" },
- { type: "system", kind: "model-changed", text: "c" },
- ]);
- });
-
- it("returns the same messageId across appends to the same system message", () => {
- counter = 0;
- const messages: Msg[] = [];
- const first = applySystemEvent(messages, { kind: "notice", text: "a" }, idFactory);
- const second = applySystemEvent(messages, { kind: "notice", text: "b" }, idFactory);
- expect(first.messageId).toBe(second.messageId);
- });
-
- it("works against the core ChatMessage shape (with id added by caller)", () => {
- // Sanity: ChatMessage has {role, chunks}; the caller layers id on top.
- // This test exists to prove the generic constraint doesn't reject the
- // real persistence/in-memory shape we'll see in Phase 5.
- counter = 0;
- const messages: Array<ChatMessage & { id: string }> = [];
- const result = applySystemEvent(messages, { kind: "cancelled", text: "user stop" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages[0]?.role).toBe("system");
- expect(messages[0]?.chunks[0]).toMatchObject({ kind: "cancelled", text: "user stop" });
- });
-});
diff --git a/packages/core/tests/compaction/compaction.test.ts b/packages/core/tests/compaction/compaction.test.ts
deleted file mode 100644
index d6edd59..0000000
--- a/packages/core/tests/compaction/compaction.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- buildCompactionPrompt,
- buildCompactionRequest,
- buildSummaryTurnText,
- DEFAULT_TAIL_TURNS,
- extractPreviousSummary,
- renderTranscript,
- SUMMARY_MARKER,
- SUMMARY_TEMPLATE,
- selectHeadTail,
-} from "../../src/compaction/index.js";
-import type { ChatMessage } from "../../src/types/index.js";
-
-function user(text: string): ChatMessage {
- return { role: "user", chunks: [{ type: "text", text }] };
-}
-function assistant(text: string): ChatMessage {
- return { role: "assistant", chunks: [{ type: "text", text }] };
-}
-
-describe("selectHeadTail", () => {
- it("returns empty head when turns <= tailTurns (nothing to compact)", () => {
- const msgs = [user("u1"), assistant("a1"), user("u2"), assistant("a2")];
- const { head, tail } = selectHeadTail(msgs, 2);
- expect(head).toEqual([]);
- expect(tail).toEqual(msgs);
- });
-
- it("keeps the last N turns verbatim and summarizes the rest", () => {
- const msgs = [
- user("u1"),
- assistant("a1"),
- user("u2"),
- assistant("a2"),
- user("u3"),
- assistant("a3"),
- ];
- const { head, tail } = selectHeadTail(msgs, 2);
- expect(tail[0]).toBe(msgs[2]);
- expect(tail.at(-1)).toBe(msgs[5]);
- expect(head).toEqual([msgs[0], msgs[1]]);
- });
-
- it("a turn includes trailing assistant/tool messages up to the next user", () => {
- const msgs = [user("u1"), assistant("a1a"), assistant("a1b"), user("u2"), assistant("a2")];
- const { head, tail } = selectHeadTail(msgs, 1);
- expect(head).toEqual([msgs[0], msgs[1], msgs[2]]);
- expect(tail).toEqual([msgs[3], msgs[4]]);
- });
-
- it("tailTurns<=0 → everything is head", () => {
- const msgs = [user("u1"), assistant("a1")];
- expect(selectHeadTail(msgs, 0)).toEqual({ head: msgs, tail: [] });
- });
-
- it("defaults to DEFAULT_TAIL_TURNS", () => {
- const msgs = [user("u1"), user("u2"), user("u3")];
- const def = selectHeadTail(msgs);
- const explicit = selectHeadTail(msgs, DEFAULT_TAIL_TURNS);
- expect(def).toEqual(explicit);
- });
-});
-
-describe("buildCompactionPrompt", () => {
- it("creates a fresh-summary instruction without a previous summary", () => {
- const p = buildCompactionPrompt({});
- expect(p).toContain("Create a new anchored summary");
- expect(p).toContain(SUMMARY_TEMPLATE);
- expect(p).not.toContain("<previous-summary>");
- });
-
- it("anchors on a previous summary when provided", () => {
- const p = buildCompactionPrompt({ previousSummary: "## Goal\n- old" });
- expect(p).toContain("Update the anchored summary");
- expect(p).toContain("<previous-summary>");
- expect(p).toContain("## Goal\n- old");
- expect(p).toContain(SUMMARY_TEMPLATE);
- });
-});
-
-describe("extractPreviousSummary", () => {
- it("returns undefined when the first user message is not a seeded summary", () => {
- expect(extractPreviousSummary([user("hello"), assistant("hi")])).toBeUndefined();
- });
-
- it("extracts the body of a seeded summary turn (marker stripped)", () => {
- const seeded = user(buildSummaryTurnText("## Goal\n- build X"));
- expect(extractPreviousSummary([seeded, assistant("ok")])).toBe("## Goal\n- build X");
- });
-});
-
-describe("renderTranscript", () => {
- it("renders user/assistant text blocks", () => {
- const t = renderTranscript([user("hello"), assistant("hi there")]);
- expect(t).toContain("## User\nhello");
- expect(t).toContain("## Assistant\nhi there");
- });
-
- it("renders tool calls and caps long tool results", () => {
- const big = "x".repeat(5000);
- const msg: ChatMessage = {
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [{ id: "c1", name: "read_file", arguments: { path: "a" }, result: big }],
- },
- ],
- };
- const t = renderTranscript([msg], 2000);
- expect(t).toContain('[tool read_file {"path":"a"}]');
- expect(t).toContain("chars truncated for summary");
- expect(t.length).toBeLessThan(5000 + 200);
- });
-
- it("skips a seeded prior-summary user turn", () => {
- const seeded = user(buildSummaryTurnText("## Goal\n- prior"));
- const t = renderTranscript([seeded, assistant("work")]);
- expect(t).not.toContain("prior");
- expect(t).toContain("## Assistant\nwork");
- });
-
- it("omits thinking/error/system chunks", () => {
- const msg: ChatMessage = {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "secret reasoning" },
- { type: "text", text: "visible" },
- { type: "error", message: "boom" },
- ],
- };
- const t = renderTranscript([msg]);
- expect(t).toContain("visible");
- expect(t).not.toContain("secret reasoning");
- expect(t).not.toContain("boom");
- });
-});
-
-describe("buildCompactionRequest", () => {
- it("returns no prompt when there is nothing to compact", () => {
- const msgs = [user("u1"), assistant("a1")];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.prompt).toBeUndefined();
- expect(req.head).toEqual([]);
- expect(req.tail).toEqual(msgs);
- });
-
- it("builds a prompt with transcript + instruction and exposes head/tail", () => {
- const msgs = [
- user("first task"),
- assistant("did stuff"),
- user("second"),
- assistant("more"),
- user("third"),
- assistant("done"),
- ];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.prompt).toBeDefined();
- expect(req.prompt).toContain("first task");
- expect(req.prompt).toContain("Create a new anchored summary");
- expect(req.tail[0]).toBe(msgs[2]);
- expect(req.head[0]).toBe(msgs[0]);
- });
-
- it("anchors on a prior seeded summary", () => {
- const msgs = [
- user(buildSummaryTurnText("## Goal\n- old goal")),
- assistant("ack"),
- user("new work"),
- assistant("did new"),
- user("more work"),
- assistant("did more"),
- ];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.previousSummary).toBe("## Goal\n- old goal");
- expect(req.prompt).toContain("Update the anchored summary");
- // head = [seeded-summary, "ack"]; seeded summary is skipped in the
- // transcript, so "ack" represents the summarized head. "new work" lives
- // in the preserved tail (last 2 turns), not the summary body.
- expect(req.prompt).toContain("ack");
- expect(
- req.tail.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "new work")),
- ).toBe(true);
- });
-});
-
-describe("buildSummaryTurnText", () => {
- it("prefixes the marker so a later compaction can anchor", () => {
- const seeded = buildSummaryTurnText("## Goal\n- x");
- expect(seeded.startsWith(SUMMARY_MARKER)).toBe(true);
- expect(extractPreviousSummary([user(seeded)])).toBe("## Goal\n- x");
- });
-});
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
deleted file mode 100644
index 0d84d0b..0000000
--- a/packages/core/tests/config/loader.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join, sep } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { configToRuleset, loadConfig } from "../../src/config/loader.js";
-
-const TMP = join("/tmp/opencode", "dispatch-config-test");
-
-// Point the global config at a path that does not exist so these tests are
-// hermetic — they must not pick up this machine's real
-// ~/.config/dispatch/dispatch.toml.
-const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
-
-beforeEach(() => {
- mkdirSync(TMP, { recursive: true });
- process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml");
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
- if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
- else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
-});
-
-function writeToml(content: string): void {
- writeFileSync(join(TMP, "dispatch.toml"), content, "utf-8");
-}
-
-describe("loadConfig", () => {
- it("returns empty permissions when dispatch.toml is missing", () => {
- const config = loadConfig(TMP);
- expect(config.permissions).toEqual({});
- });
-
- it("parses simple string permissions", () => {
- writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`);
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- expect(config.permissions.edit).toBe("deny");
- });
-
- it("parses nested pattern permissions", () => {
- writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["npm test"]).toBe("allow");
- expect(bash["*"]).toBe("ask");
- });
-
- it("ignores comment lines", () => {
- writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`);
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- });
-
- it("handles ~ expansion in nested keys", () => {
- writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read["~/projects/*"]).toBe("allow");
- });
-
- it("handles $HOME expansion in nested keys", () => {
- writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read["$HOME/docs/*"]).toBe("allow");
- });
-
- it("parses quoted keys", () => {
- writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["git commit *"]).toBe("allow");
- expect(bash["rm *"]).toBe("deny");
- });
-
- it("handles multiple permission groups", () => {
- writeToml(
- `[permissions]\nread = "allow"\n\n[permissions.edit]\n"*" = "ask"\n"src/**" = "allow"\n\n[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`,
- );
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- const edit = config.permissions.edit as Record<string, string>;
- expect(edit["*"]).toBe("ask");
- expect(edit["src/**"]).toBe("allow");
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["npm test"]).toBe("allow");
- expect(bash["*"]).toBe("ask");
- });
-
- it("preserves # inside quoted string keys", () => {
- writeToml(`[permissions.bash]\n"file#1" = "allow"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["file#1"]).toBe("allow");
- });
-
- it("strips inline comments on table headers", () => {
- writeToml(`[permissions.bash] # scripts\n"*" = "allow"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["*"]).toBe("allow");
- });
-
- it("expands ~ with platform path separator", () => {
- // Simulate a path using the OS separator
- const pattern = `~${sep}projects${sep}*`;
- writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read[pattern]).toBe("allow");
- });
-
- it("throws on TOML parse errors", () => {
- writeToml("this is not valid TOML [[[");
- expect(() => loadConfig(TMP)).toThrow();
- });
-});
-
-describe("configToRuleset — new validations", () => {
- it("falls back to ask and warns for invalid action in string value", () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const rules = configToRuleset({ permissions: { read: "allw" } });
- expect(rules[0]?.action).toBe("ask");
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw"));
- warn.mockRestore();
- });
-
- it("falls back to ask and warns for invalid action in nested pattern", () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } });
- expect(rules[0]?.action).toBe("ask");
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID"));
- warn.mockRestore();
- });
-
- it("expands ~ with backslash separator in patterns", () => {
- const home = homedir();
- // Force backslash path even on Linux to test the regex
- const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } });
- expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`);
- });
-});
-
-describe("configToRuleset", () => {
- it("produces a rule with pattern * for string value", () => {
- const rules = configToRuleset({ permissions: { read: "allow" } });
- expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]);
- });
-
- it("produces rules for each pattern in an object value", () => {
- const rules = configToRuleset({
- permissions: { bash: { "npm test": "allow", "*": "ask" } },
- });
- expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" });
- expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" });
- });
-
- it("expands ~ in patterns", () => {
- const home = homedir();
- const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } });
- expect(rules[0]?.pattern).toBe(`${home}/foo/*`);
- });
-
- it("expands $HOME in patterns", () => {
- const home = homedir();
- const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } });
- expect(rules[0]?.pattern).toBe(`${home}/bar/*`);
- });
-
- it("handles empty permissions", () => {
- const rules = configToRuleset({ permissions: {} });
- expect(rules).toEqual([]);
- });
-});
diff --git a/packages/core/tests/config/lsp-schema.test.ts b/packages/core/tests/config/lsp-schema.test.ts
deleted file mode 100644
index 2b71cc2..0000000
--- a/packages/core/tests/config/lsp-schema.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { validateConfig } from "../../src/config/schema.js";
-
-describe("config schema — [lsp] block", () => {
- it("parses a valid custom server entry", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: {
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- initialization: { "luau-lsp": { platform: { type: "roblox" } } },
- },
- },
- });
- expect(errors).toHaveLength(0);
- expect(config.lsp).toBeDefined();
- const entry = config.lsp?.["luau-lsp"];
- expect(entry?.command).toEqual(["luau-lsp", "lsp"]);
- expect(entry?.extensions).toEqual([".luau"]);
- expect(entry?.initialization).toEqual({
- "luau-lsp": { platform: { type: "roblox" } },
- });
- });
-
- it("preserves env and nested initialization verbatim", () => {
- const { config } = validateConfig({
- permissions: {},
- lsp: {
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- env: { PATH: "/custom/bin" },
- initialization: {
- "luau-lsp": {
- sourcemap: { enabled: true, autogenerate: true },
- diagnostics: { strictDatamodelTypes: false },
- },
- },
- },
- },
- });
- const entry = config.lsp?.["luau-lsp"];
- expect(entry?.env).toEqual({ PATH: "/custom/bin" });
- expect(entry?.initialization).toEqual({
- "luau-lsp": {
- sourcemap: { enabled: true, autogenerate: true },
- diagnostics: { strictDatamodelTypes: false },
- },
- });
- });
-
- it("rejects a custom server missing command", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: { broken: { extensions: [".luau"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
- expect(config.lsp).toBeUndefined();
- });
-
- it("rejects a custom server missing extensions", () => {
- const { errors } = validateConfig({
- permissions: {},
- lsp: { broken: { command: ["x"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.extensions")).toBe(true);
- });
-
- it("rejects an empty command array", () => {
- const { errors } = validateConfig({
- permissions: {},
- lsp: { broken: { command: [], extensions: [".luau"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
- });
-
- it("keeps a disabled entry without requiring command/extensions", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: { "luau-lsp": { disabled: true } },
- });
- expect(errors).toHaveLength(0);
- expect(config.lsp?.["luau-lsp"]?.disabled).toBe(true);
- });
-
- it("skips a malformed entry but keeps valid siblings", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: {
- good: { command: ["a"], extensions: [".luau"] },
- bad: { extensions: [".luau"] },
- },
- });
- expect(config.lsp?.good).toBeDefined();
- expect(config.lsp?.bad).toBeUndefined();
- expect(errors.length).toBeGreaterThan(0);
- });
-
- it("omits lsp entirely when not present", () => {
- const { config, errors } = validateConfig({ permissions: {} });
- expect(errors).toHaveLength(0);
- expect(config.lsp).toBeUndefined();
- });
-
- it("flags a non-object lsp value", () => {
- const { errors } = validateConfig({ permissions: {}, lsp: "nope" });
- expect(errors.some((e) => e.path === "lsp")).toBe(true);
- });
-});
diff --git a/packages/core/tests/config/merge.test.ts b/packages/core/tests/config/merge.test.ts
deleted file mode 100644
index b9e4bbb..0000000
--- a/packages/core/tests/config/merge.test.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- configToRuleset,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
-} from "../../src/config/loader.js";
-import { evaluate } from "../../src/permission/evaluate.js";
-import type { DispatchConfig } from "../../src/types/index.js";
-
-const TMP = join("/tmp/opencode", "dispatch-config-merge-test");
-const LOCAL_DIR = join(TMP, "project");
-const GLOBAL_PATH = join(TMP, "global", "dispatch.toml");
-
-const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
-
-beforeEach(() => {
- mkdirSync(LOCAL_DIR, { recursive: true });
- mkdirSync(join(TMP, "global"), { recursive: true });
- process.env.DISPATCH_GLOBAL_CONFIG = GLOBAL_PATH;
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
- if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
- else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
-});
-
-function writeGlobal(content: string): void {
- writeFileSync(GLOBAL_PATH, content, "utf-8");
-}
-
-function writeLocal(content: string): void {
- writeFileSync(join(LOCAL_DIR, "dispatch.toml"), content, "utf-8");
-}
-
-// ─── mergeConfigs (pure) ─────────────────────────────────────────
-
-describe("mergeConfigs — lsp by id", () => {
- it("keeps non-conflicting servers from both global and local", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["biome"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { luau: { command: ["luau-lsp"], extensions: [".luau"] } },
- };
- const merged = mergeConfigs(global, local);
- expect(Object.keys(merged.lsp ?? {}).sort()).toEqual(["biome", "luau"]);
- });
-
- it("local overrides global for the same server id", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["global-biome"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["local-biome"], extensions: [".ts", ".tsx"] } },
- };
- const merged = mergeConfigs(global, local);
- expect(merged.lsp?.biome.command).toEqual(["local-biome"]);
- expect(merged.lsp?.biome.extensions).toEqual([".ts", ".tsx"]);
- });
-
- it("omits lsp entirely when neither side declares one", () => {
- const merged = mergeConfigs({ permissions: {} }, { permissions: {} });
- expect(merged.lsp).toBeUndefined();
- });
-
- it("does not mutate inputs", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["g"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["l"], extensions: [".ts"] } },
- };
- mergeConfigs(global, local);
- expect(global.lsp?.biome.command).toEqual(["g"]);
- expect(local.lsp?.biome.command).toEqual(["l"]);
- });
-});
-
-describe("mergeConfigs — keys by id", () => {
- it("merges keys by id, local overriding global", () => {
- const global: DispatchConfig = {
- permissions: {},
- keys: [
- { id: "a", provider: "x", base_url: "g-a" },
- { id: "b", provider: "x", base_url: "g-b" },
- ],
- };
- const local: DispatchConfig = {
- permissions: {},
- keys: [
- { id: "b", provider: "x", base_url: "l-b" },
- { id: "c", provider: "x", base_url: "l-c" },
- ],
- };
- const merged = mergeConfigs(global, local);
- const byId = Object.fromEntries((merged.keys ?? []).map((k) => [k.id, k.base_url]));
- expect(byId).toEqual({ a: "g-a", b: "l-b", c: "l-c" });
- });
-
- it("carries global keys through when local has none", () => {
- const global: DispatchConfig = {
- permissions: {},
- keys: [{ id: "a", provider: "x", base_url: "g-a" }],
- };
- const merged = mergeConfigs(global, { permissions: {} });
- expect(merged.keys).toEqual([{ id: "a", provider: "x", base_url: "g-a" }]);
- });
-});
-
-describe("mergeConfigs — permissions", () => {
- it("merges nested groups pattern-by-pattern with local winning", () => {
- const global: DispatchConfig = {
- permissions: { bash: { "git status": "allow", "*": "ask" } },
- };
- const local: DispatchConfig = {
- permissions: { bash: { "*": "allow" } },
- };
- const merged = mergeConfigs(global, local);
- const bash = merged.permissions.bash as Record<string, string>;
- expect(bash["git status"]).toBe("allow");
- expect(bash["*"]).toBe("allow"); // local override
- });
-
- it("local string value replaces a global nested group", () => {
- const global: DispatchConfig = {
- permissions: { read: { "src/**": "allow" } },
- };
- const local: DispatchConfig = { permissions: { read: "deny" } };
- const merged = mergeConfigs(global, local);
- expect(merged.permissions.read).toBe("deny");
- });
-
- it("keeps global-only permission groups", () => {
- const global: DispatchConfig = { permissions: { read: "allow" } };
- const local: DispatchConfig = { permissions: { edit: "ask" } };
- const merged = mergeConfigs(global, local);
- expect(merged.permissions.read).toBe("allow");
- expect(merged.permissions.edit).toBe("ask");
- });
-
- it("local wins at evaluation time (findLast ordering)", () => {
- const merged = mergeConfigs(
- { permissions: { bash: { "*": "ask" } } },
- { permissions: { bash: { "*": "allow" } } },
- );
- const ruleset = configToRuleset(merged);
- expect(evaluate("bash", "anything", ruleset).action).toBe("allow");
- });
-
- // Regression: a SPECIFIC local override must not be shadowed by a more
- // GENERAL global pattern (e.g. "*") that happened to be declared lower in
- // the global block. `evaluate` uses findLast, so every local pattern must be
- // emitted AFTER all global patterns of the same group.
- it("specific local override beats a general global wildcard regardless of declaration order", () => {
- const merged = mergeConfigs(
- { permissions: { bash: { "npm test": "allow", "*": "ask" } } },
- { permissions: { bash: { "npm test": "deny" } } },
- );
- // Local "npm test" must be emitted after global "*".
- expect(Object.keys(merged.permissions.bash as object)).toEqual(["*", "npm test"]);
- const ruleset = configToRuleset(merged);
- expect(evaluate("bash", "npm test", ruleset).action).toBe("deny");
- // And the inherited global wildcard still applies to other commands.
- expect(evaluate("bash", "rm -rf /", ruleset).action).toBe("ask");
- });
-});
-
-// ─── loadConfig (filesystem integration) ─────────────────────────
-
-describe("loadConfig — global + local integration", () => {
- it("returns global config when local dispatch.toml is missing", () => {
- writeGlobal(`[lsp.biome]\ncommand = ["biome"]\nextensions = [".ts"]\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.lsp?.biome.command).toEqual(["biome"]);
- });
-
- it("returns local config when global is missing", () => {
- writeLocal(`[permissions]\nread = "allow"\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.permissions.read).toBe("allow");
- expect(config.lsp).toBeUndefined();
- });
-
- it("merges global LSP servers with local ones (local wins on id)", () => {
- writeGlobal(
- `[lsp.biome]\ncommand = ["global-biome"]\nextensions = [".ts"]\n\n[lsp.luau]\ncommand = ["luau-lsp"]\nextensions = [".luau"]\n`,
- );
- writeLocal(`[lsp.biome]\ncommand = ["local-biome"]\nextensions = [".ts"]\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.lsp?.biome.command).toEqual(["local-biome"]);
- expect(config.lsp?.luau.command).toEqual(["luau-lsp"]);
- });
-
- it("a malformed global config is ignored, local still loads", () => {
- writeGlobal("not valid toml [[[");
- writeLocal(`[permissions]\nread = "allow"\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.permissions.read).toBe("allow");
- });
-});
-
-describe("loadGlobalConfig", () => {
- it("returns empty default when the global file is missing", () => {
- expect(loadGlobalConfig()).toEqual({ permissions: {} });
- });
-
- it("loads the file at getGlobalConfigPath()", () => {
- writeGlobal(`[permissions]\nedit = "deny"\n`);
- expect(getGlobalConfigPath()).toBe(GLOBAL_PATH);
- expect(loadGlobalConfig().permissions.edit).toBe("deny");
- });
-});
diff --git a/packages/core/tests/config/watcher.test.ts b/packages/core/tests/config/watcher.test.ts
deleted file mode 100644
index 9388c8a..0000000
--- a/packages/core/tests/config/watcher.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { watchDirConfig } from "../../src/config/watcher.js";
-
-const TMP = join("/tmp/opencode", "dispatch-watchdir-test");
-
-beforeEach(() => {
- mkdirSync(TMP, { recursive: true });
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
-});
-
-function wait(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-describe("watchDirConfig", () => {
- it("fires onChange (debounced) when <dir>/dispatch.toml changes", async () => {
- let calls = 0;
- const handle = watchDirConfig(TMP, () => {
- calls++;
- });
- // Let chokidar finish its initial scan before mutating the file.
- await wait(200);
-
- writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nread = "allow"\n`, "utf-8");
- // 300ms debounce + chokidar latency.
- await wait(700);
-
- handle.close();
- expect(calls).toBeGreaterThanOrEqual(1);
- });
-
- it("does not fire after close()", async () => {
- let calls = 0;
- const handle = watchDirConfig(TMP, () => {
- calls++;
- });
- await wait(200);
- handle.close();
-
- writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nedit = "deny"\n`, "utf-8");
- await wait(700);
-
- expect(calls).toBe(0);
- });
-});
diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts
deleted file mode 100644
index a97a00c..0000000
--- a/packages/core/tests/credentials/wake-probe.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// `claude.ts` transitively imports `db/index.js`, whose top-level
-// `import { Database } from "bun:sqlite"` can't resolve under vitest's Node
-// runtime. Stub the db module — `buildWakeProbeBody` never touches it.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-const { buildWakeProbeBody, selectHaikuModel } = await import("../../src/credentials/claude.js");
-
-const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-
-describe("buildWakeProbeBody", () => {
- it("targets the requested model with a tiny token budget", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.model).toBe("claude-3-5-haiku-20241022");
- expect(body.max_tokens).toBe(16);
- });
-
- it("emits a Claude-Code-shaped system[]: billing first, identity second", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.system).toHaveLength(2);
-
- // system[0] is the billing header line (no cache_control on a probe).
- expect(body.system[0]).toEqual({
- type: "text",
- text: expect.stringMatching(/^x-anthropic-billing-header: /),
- });
- expect(body.system[0]).not.toHaveProperty("cache_control");
-
- // system[1] is the VERBATIM Claude Code identity string. Anthropic
- // rejects OAuth (Pro/Max) requests whose system[] lacks this.
- expect(body.system[1]).toEqual({ type: "text", text: IDENTITY });
- });
-
- it("carries a single short user message", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.messages).toEqual([{ role: "user", content: "hi" }]);
- });
-
- it("is deterministic for a given model (pure)", () => {
- const a = buildWakeProbeBody("claude-3-5-haiku-20241022");
- const b = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(a).toEqual(b);
- });
-});
-describe("selectHaikuModel", () => {
- it("returns the id whose name contains 'haiku'", () => {
- const models = ["claude-sonnet-4-20250514", "claude-haiku-4-5-20251001"];
- expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
- });
-
- it("matches case-insensitively", () => {
- expect(selectHaikuModel(["Claude-HAIKU-Latest"])).toBe("Claude-HAIKU-Latest");
- });
-
- it("returns the FIRST match when several models contain 'haiku'", () => {
- // `/v1/models` returns newest-first, so first-match prefers the newest.
- const models = ["claude-haiku-4-5-20251001", "claude-3-5-haiku-20241022"];
- expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
- });
-
- it("returns null when no model contains 'haiku'", () => {
- expect(selectHaikuModel(["claude-sonnet-4-20250514", "claude-opus-4-20250514"])).toBeNull();
- });
-
- it("returns null for an empty list", () => {
- expect(selectHaikuModel([])).toBeNull();
- });
-});
diff --git a/packages/core/tests/db/chunks.db.test.ts b/packages/core/tests/db/chunks.db.test.ts
deleted file mode 100644
index 4f7d517..0000000
--- a/packages/core/tests/db/chunks.db.test.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ChunkRowDraft, UsageData } from "../../src/types/index.js";
-
-/**
- * Internal row shape — matches the production `chunks` table columns.
- * Kept loose at the `query()` boundary to mirror bun:sqlite's dynamic
- * return type.
- */
-interface ChunkRecord {
- id: string;
- tab_id: string;
- seq: number;
- turn_id: string;
- step: number;
- role: string;
- type: string;
- data_json: string;
- created_at: number;
-}
-
-/**
- * In-memory fake of `bun:sqlite`'s Database implementing only the queries
- * `chunks.ts` actually issues. Same approach as `tabs.test.ts`: match exact
- * normalized query strings as fixed branches (no SQL parser), so a query-string
- * change fails loudly as "unsupported" instead of silently returning wrong data.
- *
- * This lets the DB-backed `getChunksForTab` / `getTotalChunkCount` /
- * `getUsageStatsForTab` logic run under vitest, where `bun:sqlite` can't load.
- */
-class FakeDatabase {
- rows: ChunkRecord[] = [];
- private idCounter = 0;
-
- query(sql: string): {
- all: (params?: Record<string, unknown>) => unknown[];
- get: (params?: Record<string, unknown>) => unknown;
- run: (params?: Record<string, unknown>) => void;
- } {
- return {
- all: (params) => this.execSelect(sql, params),
- get: (params) => this.execSelect(sql, params)[0] ?? null,
- run: (params) => {
- this.execMutation(sql, params);
- },
- };
- }
-
- /** bun:sqlite's `db.transaction(fn)` returns a callable that runs `fn`. */
- transaction(fn: () => void): () => void {
- return () => {
- fn();
- };
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
- const tabId = params?.$tabId as string | undefined;
- const forTab = this.rows.filter((r) => r.tab_id === tabId);
- const visible = forTab.filter((r) => r.type !== "usage");
-
- // appendChunks: next-seq lookup (counts ALL rows, incl. usage)
- if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") {
- const seqs = forTab.map((r) => r.seq);
- return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }];
- }
-
- // getChunksForTab — no options (usage excluded)
- if (
- norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC"
- ) {
- return [...visible].sort((a, b) => a.seq - b.seq);
- }
-
- // getChunksForTab — before + limit (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit"
- ) {
- const before = params?.$before as number;
- const limit = params?.$limit as number;
- return visible
- .filter((r) => r.seq < before)
- .sort((a, b) => b.seq - a.seq)
- .slice(0, limit);
- }
-
- // getChunksForTab — before only (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC"
- ) {
- const before = params?.$before as number;
- return visible.filter((r) => r.seq < before).sort((a, b) => b.seq - a.seq);
- }
-
- // getChunksForTab — limit only (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit"
- ) {
- const limit = params?.$limit as number;
- return [...visible].sort((a, b) => b.seq - a.seq).slice(0, limit);
- }
-
- // getTotalChunkCount (usage excluded)
- if (norm === "SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") {
- return [{ count: visible.length }];
- }
-
- // getUsageStatsForTab: usage rows only, in seq order
- if (
- norm ===
- "SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC"
- ) {
- return forTab
- .filter((r) => r.type === "usage")
- .sort((a, b) => a.seq - b.seq)
- .map((r) => ({ data_json: r.data_json }));
- }
-
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): void {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // appendChunks: single-row insert
- if (
- norm ===
- "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)"
- ) {
- this.rows.push({
- id: (params?.$id as string) ?? `c${this.idCounter++}`,
- tab_id: params?.$tabId as string,
- seq: params?.$seq as number,
- turn_id: params?.$turnId as string,
- step: (params?.$step as number) ?? 0,
- role: params?.$role as string,
- type: params?.$type as string,
- data_json: params?.$dataJson as string,
- created_at: (params?.$now as number) ?? 0,
- });
- return;
- }
-
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-let fakeDb: FakeDatabase;
-
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => fakeDb),
-}));
-
-const { appendChunks, getChunksForTab, getTotalChunkCount, getUsageStatsForTab } = await import(
- "../../src/db/chunks.js"
-);
-
-function usageDraft(turnId: string, u: UsageData): ChunkRowDraft {
- return { turnId, step: 0, role: "assistant", type: "usage", data: u };
-}
-
-beforeAll(() => {
- fakeDb = new FakeDatabase();
-});
-
-beforeEach(() => {
- fakeDb.rows = [];
-});
-
-// ---------------------------------------------------------------------------
-// usage chunk persistence + side-channel invariants
-// ---------------------------------------------------------------------------
-describe("usage chunk rows (DB-backed)", () => {
- const TAB = "tab-usage";
-
- it("persists usage rows alongside content rows with contiguous seqs", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- ]);
- // All three rows landed with contiguous seqs.
- expect(fakeDb.rows.map((r) => r.seq)).toEqual([0, 1, 2]);
- expect(fakeDb.rows.map((r) => r.type)).toEqual(["text", "text", "usage"]);
- });
-
- it("excludes usage rows from getChunksForTab (all variants)", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- usageDraft("t1", {
- inputTokens: 200,
- outputTokens: 20,
- cacheReadTokens: 150,
- cacheWriteTokens: 0,
- }),
- ]);
-
- // no options
- const all = getChunksForTab(TAB);
- expect(all.every((r) => r.type !== "usage")).toBe(true);
- expect(all.map((r) => r.type)).toEqual(["text", "text"]);
-
- // limit only
- const limited = getChunksForTab(TAB, { limit: 10 });
- expect(limited.every((r) => r.type !== "usage")).toBe(true);
- expect(limited).toHaveLength(2);
-
- // before only — `before` is a seq cursor; usage seqs must never surface
- const before = getChunksForTab(TAB, { before: 100 });
- expect(before.every((r) => r.type !== "usage")).toBe(true);
- expect(before).toHaveLength(2);
-
- // before + limit
- const bl = getChunksForTab(TAB, { before: 100, limit: 10 });
- expect(bl.every((r) => r.type !== "usage")).toBe(true);
- expect(bl).toHaveLength(2);
- });
-
- it("excludes usage rows from getTotalChunkCount", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- ]);
- // 3 rows total, but only 2 visible.
- expect(getTotalChunkCount(TAB)).toBe(2);
- });
-});
-
-// ---------------------------------------------------------------------------
-// getUsageStatsForTab — backend aggregate
-// ---------------------------------------------------------------------------
-describe("getUsageStatsForTab", () => {
- const TAB = "tab-agg";
-
- it("returns null when the tab has no usage rows", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- ]);
- expect(getUsageStatsForTab(TAB)).toBeNull();
- });
-
- it("sums cumulative tokens, counts requests, and reports the last request's split", () => {
- appendChunks(TAB, [
- usageDraft("t1", {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- }),
- usageDraft("t1", {
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- }),
- ]);
-
- const stats = getUsageStatsForTab(TAB);
- expect(stats).not.toBeNull();
- expect(stats?.requests).toBe(2);
- expect(stats?.inputTokens).toBe(2200);
- expect(stats?.outputTokens).toBe(100);
- expect(stats?.cacheReadTokens).toBe(1000);
- expect(stats?.cacheWriteTokens).toBe(1000);
- // `last` = the most recent (highest-seq) usage row.
- expect(stats?.last).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("is structurally identical to the frontend CacheStats shape (seeds directly)", () => {
- appendChunks(TAB, [
- usageDraft("t1", {
- inputTokens: 5,
- outputTokens: 1,
- cacheReadTokens: 2,
- cacheWriteTokens: 3,
- }),
- ]);
- const stats = getUsageStatsForTab(TAB);
- expect(Object.keys(stats ?? {}).sort()).toEqual(
- [
- "cacheReadTokens",
- "cacheWriteTokens",
- "inputTokens",
- "last",
- "outputTokens",
- "requests",
- ].sort(),
- );
- });
-
- it("is scoped per tab", () => {
- appendChunks("tab-a", [
- usageDraft("t1", {
- inputTokens: 10,
- outputTokens: 1,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- ]);
- appendChunks("tab-b", [
- usageDraft("t2", {
- inputTokens: 20,
- outputTokens: 2,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- ]);
- expect(getUsageStatsForTab("tab-a")?.inputTokens).toBe(10);
- expect(getUsageStatsForTab("tab-b")?.inputTokens).toBe(20);
- });
-});
diff --git a/packages/core/tests/db/chunks.test.ts b/packages/core/tests/db/chunks.test.ts
deleted file mode 100644
index fe54628..0000000
--- a/packages/core/tests/db/chunks.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { explodeTurn, explodeUserText, groupRowsToMessages } from "../../src/chunks/transform.js";
-import type { Chunk, ChunkRow, ChunkRowDraft } from "../../src/types/index.js";
-
-// These tests cover the pure explode/group transforms — the heart of the flat
-// chunk-log storage model. No DB is required.
-
-/** Promote drafts to rows with synthetic seq/id/createdAt (as appendChunks would). */
-function toRows(drafts: ChunkRowDraft[], tabId = "tab-1", startSeq = 0): ChunkRow[] {
- return drafts.map((d, i) => ({
- id: `c${i}`,
- tabId,
- seq: startSeq + i,
- turnId: d.turnId,
- step: d.step,
- role: d.role,
- type: d.type,
- data: d.data,
- createdAt: 1000 + i,
- }));
-}
-
-describe("explodeTurn", () => {
- it("splits a tool-batch into separate tool_call (assistant) and tool_result (tool) rows", () => {
- const chunks: Chunk[] = [
- { type: "thinking", text: "hmm", metadata: { anthropic: { signature: "S" } } },
- { type: "text", text: "let me read" },
- {
- type: "tool-batch",
- calls: [
- { id: "a1", name: "read_file", arguments: { path: "x" }, result: "X", isError: false },
- { id: "a2", name: "read_file", arguments: { path: "y" }, result: "Y", isError: false },
- ],
- },
- ];
- const drafts = explodeTurn("turn-1", chunks);
-
- // thinking, text, tool_call×2 (assistant), tool_result×2 (tool)
- expect(drafts.map((d) => `${d.role}/${d.type}`)).toEqual([
- "assistant/thinking",
- "assistant/text",
- "assistant/tool_call",
- "assistant/tool_call",
- "tool/tool_result",
- "tool/tool_result",
- ]);
- // All in the same step (one round-trip).
- expect(drafts.every((d) => d.step === 0)).toBe(true);
- expect(drafts.every((d) => d.turnId === "turn-1")).toBe(true);
- });
-
- it("increments step after each tool-batch (multi-step turn)", () => {
- const chunks: Chunk[] = [
- { type: "text", text: "s0" },
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "r" }] },
- { type: "text", text: "s1" },
- { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "r" }] },
- { type: "text", text: "final" },
- ];
- const drafts = explodeTurn("turn-1", chunks);
- const byStep = (s: number) => drafts.filter((d) => d.step === s).map((d) => d.type);
- expect(byStep(0)).toEqual(["text", "tool_call", "tool_result"]);
- expect(byStep(1)).toEqual(["text", "tool_call", "tool_result"]);
- expect(byStep(2)).toEqual(["text"]); // trailing final-step text, no tool-batch
- });
-
- it("omits tool_result rows for calls without a result", () => {
- const chunks: Chunk[] = [
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {} }] },
- ];
- const drafts = explodeTurn("turn-1", chunks);
- expect(drafts.map((d) => d.type)).toEqual(["tool_call"]);
- });
-});
-
-describe("groupRowsToMessages (round-trip)", () => {
- it("reconstructs a user message then an assistant message with a per-step tool-batch", () => {
- const rows = [
- ...toRows(explodeUserText("turn-1", "hello"), "tab-1", 0),
- ...toRows(
- explodeTurn("turn-1", [
- { type: "text", text: "reading" },
- {
- type: "tool-batch",
- calls: [
- {
- id: "a1",
- name: "read_file",
- arguments: { path: "x" },
- result: "X",
- isError: false,
- },
- ],
- },
- { type: "text", text: "done" },
- ]),
- "tab-1",
- 1,
- ),
- ];
-
- const msgs = groupRowsToMessages(rows);
- expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"]);
- expect(msgs[0]?.chunks).toEqual([{ type: "text", text: "hello" }]);
-
- const a = msgs[1];
- if (!a) throw new Error("no assistant message");
- // reconstructed: text, tool-batch(step0), text(step1)
- expect(a.chunks.map((c) => c.type)).toEqual(["text", "tool-batch", "text"]);
- const batch = a.chunks.find((c) => c.type === "tool-batch");
- if (batch?.type !== "tool-batch") throw new Error("no batch");
- expect(batch.calls[0]).toMatchObject({
- id: "a1",
- name: "read_file",
- arguments: { path: "x" },
- result: "X",
- isError: false,
- });
- });
-
- it("keeps each step's tool calls in its own tool-batch chunk", () => {
- const rows = toRows(
- explodeTurn("turn-1", [
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "ra" }] },
- { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "rb" }] },
- ]),
- );
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- const batches = msgs[0]?.chunks.filter((c) => c.type === "tool-batch") ?? [];
- expect(batches).toHaveLength(2);
- });
-
- it("round-trips a multi-step assistant turn back to its original chunk shape", () => {
- const original: Chunk[] = [
- { type: "thinking", text: "plan", metadata: { anthropic: { signature: "S" } } },
- { type: "text", text: "step0" },
- {
- type: "tool-batch",
- calls: [
- { id: "a", name: "read_file", arguments: { path: "p" }, result: "R", isError: false },
- ],
- },
- { type: "text", text: "final" },
- ];
- const rows = toRows(explodeTurn("turn-1", original));
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- expect(msgs[0]?.chunks).toEqual(original);
- });
-
- it("tolerates an orphan tool_result whose tool_call was paged out", () => {
- const rows = toRows([
- {
- turnId: "turn-1",
- step: 0,
- role: "tool",
- type: "tool_result",
- data: { callId: "z", name: "t", result: "R", isError: false },
- },
- ]);
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- const batch = msgs[0]?.chunks[0];
- if (batch?.type !== "tool-batch") throw new Error("no batch");
- expect(batch.calls[0]).toMatchObject({ id: "z", result: "R" });
- });
-
- it("breaks the assistant grouping on a user or system row", () => {
- const rows = [
- ...toRows(explodeUserText("t1", "q1"), "tab", 0),
- ...toRows(explodeTurn("t1", [{ type: "text", text: "a1" }]), "tab", 1),
- ...toRows(explodeUserText("t2", "q2"), "tab", 2),
- ...toRows(explodeTurn("t2", [{ type: "system", kind: "notice", text: "n" }]), "tab", 3),
- ];
- const msgs = groupRowsToMessages(rows);
- expect(msgs.map((m) => m.role)).toEqual(["user", "assistant", "user", "system"]);
- });
-});
diff --git a/packages/core/tests/db/rekey-chunks.db.test.ts b/packages/core/tests/db/rekey-chunks.db.test.ts
deleted file mode 100644
index 7cdafe3..0000000
--- a/packages/core/tests/db/rekey-chunks.db.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-interface ChunkRecord {
- id: string;
- tab_id: string;
- seq: number;
- turn_id: string;
- step: number;
- role: string;
- type: string;
- data_json: string;
- created_at: number;
-}
-
-/**
- * Minimal in-memory fake of bun:sqlite supporting only the queries
- * `appendChunks`, `getChunksForTab`, and `rekeyChunks` issue. Mirrors the
- * approach in chunks.db.test.ts (exact normalized-string branches).
- */
-class FakeDatabase {
- rows: ChunkRecord[] = [];
-
- query(sql: string) {
- return {
- all: (params?: Record<string, unknown>) => this.execSelect(sql, params),
- get: (params?: Record<string, unknown>) => this.execSelect(sql, params)[0] ?? null,
- run: (params?: Record<string, unknown>) => this.execMutation(sql, params),
- };
- }
-
- transaction(fn: () => void): () => void {
- return () => fn();
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
- const tabId = params?.$tabId as string | undefined;
- const forTab = this.rows.filter((r) => r.tab_id === tabId);
- const visible = forTab.filter((r) => r.type !== "usage");
-
- if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") {
- const seqs = forTab.map((r) => r.seq);
- return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }];
- }
- if (
- norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC"
- ) {
- return [...visible].sort((a, b) => a.seq - b.seq);
- }
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): { changes: number } {
- const norm = sql.replace(/\s+/g, " ").trim();
- if (
- norm ===
- "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)"
- ) {
- this.rows.push({
- id: params?.$id as string,
- tab_id: params?.$tabId as string,
- seq: params?.$seq as number,
- turn_id: params?.$turnId as string,
- step: (params?.$step as number) ?? 0,
- role: params?.$role as string,
- type: params?.$type as string,
- data_json: params?.$dataJson as string,
- created_at: (params?.$now as number) ?? 0,
- });
- return { changes: 1 };
- }
- if (norm === "UPDATE chunks SET tab_id = $to WHERE tab_id = $from") {
- const from = params?.$from as string;
- const to = params?.$to as string;
- let changes = 0;
- for (const r of this.rows) {
- if (r.tab_id === from) {
- r.tab_id = to;
- changes++;
- }
- }
- return { changes };
- }
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-let fakeDb: FakeDatabase;
-vi.mock("../../src/db/index.js", () => ({ getDatabase: vi.fn(() => fakeDb) }));
-
-const { appendChunks, getChunksForTab, rekeyChunks } = await import("../../src/db/chunks.js");
-
-beforeEach(() => {
- fakeDb = new FakeDatabase();
-});
-
-describe("rekeyChunks", () => {
- it("relocates all rows from one tab to another and reports the count", () => {
- appendChunks("src", [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } },
- ]);
- expect(getChunksForTab("src")).toHaveLength(2);
-
- const moved = rekeyChunks("src", "backup");
- expect(moved).toBe(2);
- expect(getChunksForTab("src")).toHaveLength(0);
- const dst = getChunksForTab("backup");
- expect(dst).toHaveLength(2);
- // turn id + seq preserved on the destination
- expect(dst.map((r) => r.turnId)).toEqual(["t1", "t1"]);
- expect(dst.map((r) => r.seq)).toEqual([0, 1]);
- });
-
- it("returns 0 when the source tab has no rows", () => {
- expect(rekeyChunks("nope", "backup")).toBe(0);
- });
-
- it("does not touch unrelated tabs", () => {
- appendChunks("src", [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "a" } },
- ]);
- appendChunks("other", [
- { turnId: "t9", step: 0, role: "user", type: "text", data: { text: "b" } },
- ]);
- rekeyChunks("src", "backup");
- expect(getChunksForTab("other")).toHaveLength(1);
- });
-});
diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts
deleted file mode 100644
index 2cd226b..0000000
--- a/packages/core/tests/db/tabs.test.ts
+++ /dev/null
@@ -1,418 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-
-/**
- * Internal row shape — matches the production `tabs` table columns.
- * Kept loose (`Record`) on the `query()` boundary to mirror bun:sqlite's
- * dynamic return type.
- */
-interface TabRow {
- id: string;
- title: string;
- key_id: string | null;
- model_id: string | null;
- parent_tab_id: string | null;
- status: string;
- is_open: number;
- position: number;
- created_at: number;
- updated_at: number;
-}
-
-/**
- * In-memory fake of `bun:sqlite`'s Database that implements only the
- * queries actually issued by `tabs.ts`. This sidesteps two problems
- * the original test had:
- * 1. Vite's resolver can't load `bun:sqlite` (it's a Bun-native
- * module with no on-disk file).
- * 2. Even under `bun --bun vitest`, `vi.mock` doesn't intercept
- * module imports because Bun's loader bypasses Vite's transforms.
- *
- * By implementing the exact query strings as fixed branches we avoid
- * writing an SQL parser; if `tabs.ts` ever changes a query string,
- * tests will fail loudly with "Unsupported query" instead of
- * silently returning wrong data.
- */
-class FakeDatabase {
- rows: TabRow[] = [];
-
- /** Match production's `db.query(sql).get|all|run(params)` shape. */
- query(sql: string): {
- all: (params?: Record<string, unknown>) => unknown[];
- get: (params?: Record<string, unknown>) => unknown;
- run: (params?: Record<string, unknown>) => void;
- } {
- return {
- all: (params) => this.execSelect(sql, params),
- get: (params) => this.execSelect(sql, params)[0] ?? null,
- run: (params) => {
- this.execMutation(sql, params);
- },
- };
- }
-
- /**
- * Match Bun's `db.transaction(fn)` shape: returns a callable that runs
- * `fn` synchronously. The fake is in-memory and single-threaded, so we
- * don't emulate rollback — callers just need the wrapper to be invocable.
- */
- transaction(fn: () => void): () => void {
- return () => fn();
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // getDescendantIds: children-of query
- if (norm === "SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1") {
- return this.rows
- .filter((r) => r.parent_tab_id === params?.$id && r.is_open === 1)
- .map((r) => ({ id: r.id }));
- }
-
- // getTab: single-row lookup
- if (norm === "SELECT * FROM tabs WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- return row ? [row] : [];
- }
-
- // createTab: next-position lookup
- if (norm === "SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") {
- const positions = this.rows.filter((r) => r.is_open === 1).map((r) => r.position);
- const maxPos = positions.length > 0 ? Math.max(...positions) : -1;
- return [{ max_pos: maxPos }];
- }
-
- // resolveTabPrefix: open tabs whose id starts with a sanitized prefix.
- // The production query binds `$prefix` as `<sanitized>%`; emulate SQLite
- // LIKE prefix semantics here (case-insensitive, `%` = "rest of string").
- if (norm === "SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") {
- const raw = String(params?.$prefix ?? "");
- const needle = raw.endsWith("%") ? raw.slice(0, -1) : raw;
- return this.rows
- .filter((r) => r.is_open === 1 && r.id.toLowerCase().startsWith(needle.toLowerCase()))
- .sort((a, b) => a.position - b.position);
- }
-
- // shortestUniquePrefix: all open tab ids.
- if (norm === "SELECT id FROM tabs WHERE is_open = 1") {
- return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id }));
- }
-
- // listOpenTabs: every open tab ordered by position.
- if (norm === "SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") {
- return this.rows.filter((r) => r.is_open === 1).sort((a, b) => a.position - b.position);
- }
-
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): void {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // createTab: full-row insert (every column named, $-bound params)
- if (
- norm ===
- "INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)"
- ) {
- const id = params?.$id as string;
- if (this.rows.some((r) => r.id === id)) {
- throw new Error(`UNIQUE constraint failed: tabs.id (${id})`);
- }
- this.rows.push({
- id,
- title: (params?.$title as string) ?? "",
- key_id: (params?.$keyId as string | null) ?? null,
- model_id: (params?.$modelId as string | null) ?? null,
- parent_tab_id: (params?.$parentTabId as string | null) ?? null,
- status: "idle",
- is_open: 1,
- position: (params?.$position as number) ?? 0,
- created_at: (params?.$now as number) ?? 0,
- updated_at: (params?.$now as number) ?? 0,
- });
- return;
- }
-
- // archiveTab: flip is_open to 0
- if (norm === "UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- if (row) {
- row.is_open = 0;
- row.updated_at = (params?.$now as number) ?? Date.now();
- }
- return;
- }
-
- // updateTabPositions: rewrite a single tab's position (run per id inside a txn)
- if (norm === "UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- if (row) {
- row.position = (params?.$position as number) ?? row.position;
- row.updated_at = (params?.$now as number) ?? Date.now();
- }
- return;
- }
-
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-/**
- * Shared instance referenced by both the test setup and the
- * `vi.mock` factory below. Declared with `let` (not `const`) so the
- * factory's closure picks up the value assigned in `beforeAll`.
- */
-let fakeDb: FakeDatabase;
-
-// Mock the db module before importing `tabs.ts` so that `getDatabase()`
-// returns our in-memory fake instead of trying to open a real SQLite
-// file. Mirrors the same pattern used by `tests/agent/agent.test.ts`.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => fakeDb),
-}));
-
-// Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to
-// the very top of the file, so by the time this line runs the mock is
-// active for `./index.js` resolution inside `tabs.ts`).
-const {
- archiveTab,
- createTab,
- getDescendantIds,
- getTab,
- listOpenTabs,
- resolveTabPrefix,
- shortestUniquePrefix,
- updateTabPositions,
-} = await import("../../src/db/tabs.js");
-
-beforeAll(() => {
- fakeDb = new FakeDatabase();
-});
-
-beforeEach(() => {
- fakeDb.rows = [];
-});
-
-// ---------------------------------------------------------------------------
-// getDescendantIds
-// ---------------------------------------------------------------------------
-describe("getDescendantIds", () => {
- it("returns only the id when the tab has no children", () => {
- createTab("root", "Root");
-
- const ids = getDescendantIds("root");
- expect(ids).toEqual(["root"]);
- });
-
- it("returns leaf-first order for a linear chain (root → child → grandchild)", () => {
- createTab("root", "Root");
- createTab("child", "Child", { parentTabId: "root" });
- createTab("grandchild", "Grandchild", { parentTabId: "child" });
-
- const ids = getDescendantIds("root");
- // Leaves first: grandchild, child, root
- expect(ids).toEqual(["grandchild", "child", "root"]);
- });
-
- it("returns leaf-first for a branching tree", () => {
- createTab("a", "A");
- createTab("b1", "B1", { parentTabId: "a" });
- createTab("b2", "B2", { parentTabId: "a" });
- createTab("c1", "C1", { parentTabId: "b1" });
- createTab("c2", "C2", { parentTabId: "b1" });
-
- const ids = getDescendantIds("a");
- // BFS: a, b1, b2, c1, c2 → reverse: c2, c1, b2, b1, a
- expect(ids).toEqual(["c2", "c1", "b2", "b1", "a"]);
- });
-
- it("skips archived descendants (is_open = 0)", () => {
- createTab("root", "Root");
- // Open child of root — should appear
- createTab("open-child", "Open", { parentTabId: "root" });
- // Archived child — should be skipped together with its descendants
- createTab("archived-child", "Archived", { parentTabId: "root" });
- archiveTab("archived-child");
- // Child of archived — data drift, should NOT appear (parent is archived)
- createTab("orphan", "Orphan", { parentTabId: "archived-child" });
-
- const ids = getDescendantIds("root");
- expect(ids).toEqual(["open-child", "root"]);
- expect(ids).not.toContain("archived-child");
- expect(ids).not.toContain("orphan");
- });
-
- it("handles a non-existent id gracefully", () => {
- const ids = getDescendantIds("does-not-exist");
- expect(ids).toEqual(["does-not-exist"]);
- });
-
- it("defends against accidental parent_tab_id cycles", () => {
- // Insert x first with a forward reference to y (y doesn't exist
- // yet — the schema has no foreign key enforcement). Then insert
- // y with parent_tab_id = x. Result: x.parent = y, y.parent = x.
- createTab("x", "X", { parentTabId: "y" });
- createTab("y", "Y", { parentTabId: "x" });
-
- // Must terminate — no infinite loop
- const ids = getDescendantIds("x");
- expect(ids).toContain("x");
- expect(ids).toContain("y");
- expect(ids).toHaveLength(2);
- });
-
- it("uses createTab helper and asserts is_open flag", () => {
- createTab("a1", "A1");
- createTab("b1", "B1", { parentTabId: "a1" });
- createTab("c1", "C1", { parentTabId: "b1" });
-
- // All three should be open
- expect(getTab("a1")?.isOpen).toBe(true);
- expect(getTab("b1")?.isOpen).toBe(true);
- expect(getTab("c1")?.isOpen).toBe(true);
-
- // getDescendantIds sees all three
- const ids = getDescendantIds("a1");
- expect(ids).toEqual(["c1", "b1", "a1"]);
-
- // Archive the leaf, then it should disappear
- archiveTab("c1");
- expect(getTab("c1")?.isOpen).toBe(false);
-
- const ids2 = getDescendantIds("a1");
- expect(ids2).toEqual(["b1", "a1"]);
- });
-});
-
-// ---------------------------------------------------------------------------
-// resolveTabPrefix — git-style short-handle resolution
-// ---------------------------------------------------------------------------
-describe("resolveTabPrefix", () => {
- it("returns none when the prefix is shorter than the minimum length", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "A");
- // 3 chars < MIN_TAB_PREFIX_LENGTH (4)
- expect(resolveTabPrefix("abc").status).toBe("none");
- });
-
- it("returns none when no open tab matches", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "A");
- expect(resolveTabPrefix("ffff").status).toBe("none");
- });
-
- it("resolves a unique 4-char prefix to the single matching tab", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- createTab("9999aaaa-0000-4000-8000-000000000000", "Beta");
- const res = resolveTabPrefix("abcd");
- expect(res.status).toBe("ok");
- if (res.status === "ok") {
- expect(res.tab.id).toBe("abcd1234-0000-4000-8000-000000000000");
- expect(res.tab.title).toBe("Alpha");
- }
- });
-
- it("resolves the full UUID (a maximal prefix)", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- const res = resolveTabPrefix("abcd1234-0000-4000-8000-000000000000");
- expect(res.status).toBe("ok");
- });
-
- it("reports ambiguity when multiple open tabs share the prefix", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- const res = resolveTabPrefix("abcd");
- expect(res.status).toBe("ambiguous");
- if (res.status === "ambiguous") {
- expect(res.matches).toHaveLength(2);
- expect(res.matches.map((m) => m.title).sort()).toEqual(["One", "Two"]);
- }
- });
-
- it("disambiguates when one more character is supplied", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- const res = resolveTabPrefix("abcd1");
- expect(res.status).toBe("ok");
- if (res.status === "ok") expect(res.tab.title).toBe("One");
- });
-
- it("matches case-insensitively (UUIDs are lowercase; LIKE is ASCII-CI)", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- const res = resolveTabPrefix("ABCD");
- expect(res.status).toBe("ok");
- });
-
- it("sanitizes LIKE wildcards so they cannot broaden the match", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- createTab("9999aaaa-0000-4000-8000-000000000000", "Beta");
- // `%` would match everything if not stripped; after sanitization the
- // query is effectively `abcd%` which matches only Alpha.
- const res = resolveTabPrefix("ab%d");
- // "ab%d" -> sanitized "abd" (3 chars) -> below min length -> none.
- expect(res.status).toBe("none");
- });
-
- it("excludes archived (closed) tabs from matches", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- archiveTab("abcd1234-0000-4000-8000-000000000000");
- expect(resolveTabPrefix("abcd").status).toBe("none");
- });
-});
-
-// ---------------------------------------------------------------------------
-// shortestUniquePrefix — display-handle derivation
-// ---------------------------------------------------------------------------
-describe("shortestUniquePrefix", () => {
- it("returns a 4-char prefix when no other open tab collides", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- expect(shortestUniquePrefix("abcd1234-0000-4000-8000-000000000000")).toBe("abcd");
- });
-
- it("grows the prefix one char at a time on a collision", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- // First differing char is at index 4, so a 5-char prefix is unique.
- expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd1");
- expect(shortestUniquePrefix("abcd2222-0000-4000-8000-000000000000")).toBe("abcd2");
- });
-
- it("ignores closed tabs when computing uniqueness", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- archiveTab("abcd2222-0000-4000-8000-000000000000");
- // With Two closed, One no longer collides → back to 4 chars.
- expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd");
- });
-});
-
-// ---------------------------------------------------------------------------
-// updateTabPositions — drag-and-drop reorder persistence
-// ---------------------------------------------------------------------------
-describe("updateTabPositions", () => {
- it("rewrites each tab's position to its index in the given order", () => {
- createTab("a", "A"); // position 0
- createTab("b", "B"); // position 1
- createTab("c", "C"); // position 2
-
- updateTabPositions(["c", "a", "b"]);
-
- // listOpenTabs orders by position → reflects the new order.
- expect(listOpenTabs().map((t) => t.id)).toEqual(["c", "a", "b"]);
- expect(getTab("c")?.position).toBe(0);
- expect(getTab("a")?.position).toBe(1);
- expect(getTab("b")?.position).toBe(2);
- });
-
- it("is a no-op for an empty list", () => {
- createTab("a", "A");
- createTab("b", "B");
- updateTabPositions([]);
- expect(listOpenTabs().map((t) => t.id)).toEqual(["a", "b"]);
- });
-
- it("ignores ids that don't exist without throwing", () => {
- createTab("a", "A");
- expect(() => updateTabPositions(["ghost", "a"])).not.toThrow();
- // "a" took index 1 in the requested order.
- expect(getTab("a")?.position).toBe(1);
- });
-});
diff --git a/packages/core/tests/fixture/lsp/fake-lsp-server.js b/packages/core/tests/fixture/lsp/fake-lsp-server.js
deleted file mode 100644
index d771ebd..0000000
--- a/packages/core/tests/fixture/lsp/fake-lsp-server.js
+++ /dev/null
@@ -1,195 +0,0 @@
-// Minimal JSON-RPC 2.0 LSP-like fake server over stdio, for testing the LSP
-// client without a real language server binary. Ported from opencode's
-// test/fixture/lsp/fake-lsp-server.js (trimmed to what dispatch's client and
-// manager exercise: initialize, didOpen/didChange, push + pull diagnostics).
-//
-// Test hooks (custom JSON-RPC methods the test driver can call):
-// test/get-initialize-params → returns the params sent to `initialize`
-// test/get-last-change → returns the last `didChange` params
-// test/publish-diagnostics → forwards a `publishDiagnostics` push
-// test/configure-pull-diagnostics → sets up pull-diagnostic responses
-// test/get-diagnostic-request-count→ how many pull requests were received
-
-let nextId = 1;
-let readBuffer = Buffer.alloc(0);
-let lastChange = null;
-let initializeParams = null;
-let diagnosticRequestCount = 0;
-let registeredCapability = false;
-let pullConfig = {
- registerOn: undefined,
- registrations: [],
- documentDiagnostics: [],
- workspaceDiagnostics: [],
- hasDiagnosticProvider: false,
-};
-
-function encode(message) {
- const json = JSON.stringify(message);
- const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`;
- return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]);
-}
-
-function decodeFrames(buffer) {
- const results = [];
- while (true) {
- const idx = buffer.indexOf("\r\n\r\n");
- if (idx === -1) break;
- const header = buffer.slice(0, idx).toString("utf8");
- const match = /Content-Length:\s*(\d+)/i.exec(header);
- const length = match ? parseInt(match[1], 10) : 0;
- const bodyStart = idx + 4;
- const bodyEnd = bodyStart + length;
- if (buffer.length < bodyEnd) break;
- results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"));
- buffer = buffer.slice(bodyEnd);
- }
- return { messages: results, rest: buffer };
-}
-
-function send(message) {
- process.stdout.write(encode(message));
-}
-function sendRequest(method, params) {
- const id = nextId++;
- send({ jsonrpc: "2.0", id, method, params });
- return id;
-}
-function sendResponse(id, result) {
- send({ jsonrpc: "2.0", id, result });
-}
-function sendNotification(method, params) {
- send({ jsonrpc: "2.0", method, params });
-}
-
-function maybeRegister(method) {
- if (pullConfig.registerOn !== method || registeredCapability) return;
- registeredCapability = true;
- sendRequest("client/registerCapability", {
- registrations: pullConfig.registrations.map((registration, index) => ({
- id: registration.id ?? `pull-${index}`,
- method: registration.method ?? "textDocument/diagnostic",
- registerOptions: registration.registerOptions ?? registration,
- })),
- });
-}
-
-function handle(raw) {
- let data;
- try {
- data = JSON.parse(raw);
- } catch {
- return;
- }
-
- if (data.method === "initialize") {
- initializeParams = data.params;
- sendResponse(data.id, {
- capabilities: {
- textDocumentSync: { change: 2, openClose: true },
- ...(pullConfig.hasDiagnosticProvider
- ? {
- diagnosticProvider: {
- identifier: "fake",
- interFileDependencies: false,
- workspaceDiagnostics: false,
- },
- }
- : {}),
- },
- });
- return;
- }
-
- if (data.method === "test/get-initialize-params") {
- sendResponse(data.id, initializeParams);
- return;
- }
-
- if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
- return;
- }
-
- if (data.method === "textDocument/didOpen") {
- maybeRegister("didOpen");
- return;
- }
-
- if (data.method === "textDocument/didChange") {
- lastChange = data.params;
- maybeRegister("didChange");
- return;
- }
-
- if (data.method === "workspace/didChangeWatchedFiles") {
- return;
- }
-
- if (data.method === "test/configure-pull-diagnostics") {
- pullConfig = {
- registerOn: data.params?.registerOn,
- registrations: data.params?.registrations ?? [],
- documentDiagnostics: data.params?.documentDiagnostics ?? [],
- workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
- hasDiagnosticProvider: data.params?.hasDiagnosticProvider ?? false,
- };
- registeredCapability = false;
- sendResponse(data.id, null);
- return;
- }
-
- if (data.method === "test/publish-diagnostics") {
- sendNotification("textDocument/publishDiagnostics", data.params);
- sendResponse(data.id, null);
- return;
- }
-
- if (data.method === "test/get-last-change") {
- sendResponse(data.id, lastChange);
- return;
- }
-
- if (data.method === "test/get-diagnostic-request-count") {
- sendResponse(data.id, diagnosticRequestCount);
- return;
- }
-
- if (data.method === "textDocument/diagnostic") {
- diagnosticRequestCount += 1;
- sendResponse(data.id, { kind: "full", items: pullConfig.documentDiagnostics });
- return;
- }
-
- if (data.method === "workspace/diagnostic") {
- diagnosticRequestCount += 1;
- sendResponse(data.id, { items: pullConfig.workspaceDiagnostics });
- return;
- }
-
- if (data.method === "textDocument/hover") {
- sendResponse(data.id, { contents: { kind: "plaintext", value: "fake hover" } });
- return;
- }
-
- if (data.method === "textDocument/definition") {
- sendResponse(data.id, [
- {
- uri: data.params?.textDocument?.uri,
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- },
- ]);
- return;
- }
-
- // Default: respond null to any other request so the client never hangs.
- if (typeof data.id !== "undefined") {
- sendResponse(data.id, null);
- }
-}
-
-process.stdin.on("data", (chunk) => {
- readBuffer = Buffer.concat([readBuffer, chunk]);
- const { messages, rest } = decodeFrames(readBuffer);
- readBuffer = rest;
- for (const message of messages) handle(message);
-});
diff --git a/packages/core/tests/llm/anthropic-oauth-transform.test.ts b/packages/core/tests/llm/anthropic-oauth-transform.test.ts
deleted file mode 100644
index a8bb156..0000000
--- a/packages/core/tests/llm/anthropic-oauth-transform.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { transformClaudeOAuthBody } from "../../src/llm/anthropic-oauth-transform.js";
-
-const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-const BILLING =
- "x-anthropic-billing-header: cc_version=2.1.112.abc; cc_entrypoint=sdk-cli; cch=12345;";
-
-interface WireBody {
- system?: Array<{ type: string; text: string; cache_control?: unknown }>;
- messages?: Array<{ role: string; content: unknown }>;
-}
-
-/**
- * Build the body shape Dispatch produces: ONE system block holding
- * `<billing>\n<identity>\n\n<systemPrompt>`, marked with cache_control by
- * `applyAnthropicCaching`.
- */
-function dispatchBody(systemPrompt: string, firstUser = "hello"): string {
- return JSON.stringify({
- model: "claude-opus-4-8",
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\n${systemPrompt}`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: firstUser }],
- });
-}
-
-function parse(out: BodyInit | null | undefined): WireBody {
- return JSON.parse(out as string) as WireBody;
-}
-
-describe("transformClaudeOAuthBody", () => {
- it("isolates the billing header as system[0] WITHOUT cache_control", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
- const sys = result.system ?? [];
- expect(sys[0]?.text).toBe(BILLING);
- expect(sys[0]?.cache_control).toBeUndefined();
- });
-
- it("keeps the identity as a separate system block and carries the cache_control there", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
- const sys = result.system ?? [];
- expect(sys[1]?.text).toBe(IDENTITY);
- expect(sys[1]?.cache_control).toEqual({ type: "ephemeral" });
- // Only billing + identity remain in system[] — the third-party prompt was moved out.
- expect(sys).toHaveLength(2);
- });
-
- it("relocates the third-party system prompt into the first user message", () => {
- const result = parse(
- transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.", "do the thing")),
- );
- const sys = result.system ?? [];
- // The system prompt must NOT appear anywhere in system[].
- expect(sys.some((b) => b.text.includes("You are Dispatch"))).toBe(false);
- // It is prepended to the first user message.
- expect(result.messages?.[0]?.content).toBe("You are Dispatch. Use tools.\n\ndo the thing");
- });
-
- it("prepends a text block when the first user message uses array content", () => {
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nDispatch instructions here.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: [{ type: "text", text: "user text" }] }],
- });
- const result = parse(transformClaudeOAuthBody(body));
- const content = result.messages?.[0]?.content as Array<{ type: string; text: string }>;
- expect(content[0]).toEqual({ type: "text", text: "Dispatch instructions here." });
- expect(content[1]).toEqual({ type: "text", text: "user text" });
- });
-
- it("does not carry cache_control to the identity when the source had none", () => {
- const body = JSON.stringify({
- system: [{ type: "text", text: `${BILLING}\n${IDENTITY}\n\nInstr.` }],
- messages: [{ role: "user", content: "hi" }],
- });
- const result = parse(transformClaudeOAuthBody(body));
- expect(result.system?.[1]?.cache_control).toBeUndefined();
- });
-
- it("keeps the prompt as a cached system block when there is no user message", () => {
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nInstr only.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [],
- });
- const result = parse(transformClaudeOAuthBody(body));
- const sys = result.system ?? [];
- expect(sys[0]?.text).toBe(BILLING);
- expect(sys[1]?.text).toBe(IDENTITY);
- expect(sys[2]?.text).toBe("Instr only.");
- expect(sys[2]?.cache_control).toEqual({ type: "ephemeral" });
- });
-
- it("leaves non-Claude-Code bodies (no identity string) untouched", () => {
- const body = JSON.stringify({
- system: [{ type: "text", text: "Some unrelated system prompt." }],
- messages: [{ role: "user", content: "hi" }],
- });
- // Returned unchanged (same reference string, byte-identical).
- expect(transformClaudeOAuthBody(body)).toBe(body);
- });
-
- it("returns non-string bodies unchanged", () => {
- const buf = new Uint8Array([1, 2, 3]);
- expect(transformClaudeOAuthBody(buf)).toBe(buf);
- expect(transformClaudeOAuthBody(undefined)).toBeUndefined();
- expect(transformClaudeOAuthBody(null)).toBeNull();
- });
-
- it("returns invalid JSON unchanged", () => {
- const garbage = "{not json";
- expect(transformClaudeOAuthBody(garbage)).toBe(garbage);
- });
-
- it("never emits more than the 4 cache_control breakpoints Anthropic allows", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("Big system prompt.")));
- const all = result.system ?? [];
- const cacheBlocks = all.filter((b) => b.cache_control != null);
- // Only the identity block is marked here — well under the limit of 4.
- expect(cacheBlocks.length).toBeLessThanOrEqual(4);
- });
-});
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
deleted file mode 100644
index c8c0877..0000000
--- a/packages/core/tests/llm/provider.test.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// Mock @ai-sdk/anthropic to capture what options createAnthropic is called with
-const mockAnthropicInstance = vi.fn((modelId: string) => ({
- specificationVersion: "v3" as const,
- provider: "anthropic.messages",
- modelId,
- supportedUrls: {},
- doGenerate: vi.fn(),
- doStream: vi.fn(),
-}));
-const mockCreateAnthropic = vi.fn(() => mockAnthropicInstance);
-vi.mock("@ai-sdk/anthropic", () => ({
- createAnthropic: mockCreateAnthropic,
-}));
-
-// Mock @ai-sdk/openai-compatible to capture what options createOpenAICompatible
-// is called with, and what model id the returned factory is called with.
-const mockOpenAICompatibleFactory = vi.fn((modelId: string) => ({
- specificationVersion: "v3" as const,
- provider: "openai-compatible",
- modelId,
- supportedUrls: {},
- doGenerate: vi.fn(),
- doStream: vi.fn(),
-}));
-const mockCreateOpenAICompatible = vi.fn(() => mockOpenAICompatibleFactory);
-vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: mockCreateOpenAICompatible,
-}));
-
-const { createProvider } = await import("../../src/llm/provider.js");
-
-describe("createProvider (default OpenAI-compatible path)", () => {
- it("does not wrap the model in a middleware layer — v6 SDK handles reasoning round-trip natively", () => {
- mockCreateOpenAICompatible.mockClear();
- mockOpenAICompatibleFactory.mockClear();
-
- const model = createProvider({
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- })("deepseek-v4-pro");
-
- // The factory should have been invoked with the model id directly,
- // without going through `wrapLanguageModel`. If a middleware were
- // still in place, the returned object would carry an `_middleware`
- // property (set by our test mock pattern). The bare provider model
- // has no such property — verifying the v4-era normalizeMessages
- // middleware is gone.
- expect(mockOpenAICompatibleFactory).toHaveBeenCalledWith("deepseek-v4-pro");
- expect((model as { _middleware?: unknown })._middleware).toBeUndefined();
- });
-
- it("passes name, apiKey, baseURL to createOpenAICompatible", () => {
- mockCreateOpenAICompatible.mockClear();
-
- createProvider({
- apiKey: "zen-key",
- baseURL: "https://opencode.ai/zen/v1",
- })("deepseek-v4-pro");
-
- // We assert by property rather than full-object equality because the
- // provider also passes a `fetch:` wrapper (the debug-logger tee). The
- // load-bearing wiring is name/apiKey/baseURL; the fetch field is
- // tested separately via the wrap-fetch tests.
- expect(mockCreateOpenAICompatible).toHaveBeenCalledOnce();
- const zenArgs = mockCreateOpenAICompatible.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(zenArgs.name).toBe("opencode-zen");
- expect(zenArgs.apiKey).toBe("zen-key");
- expect(zenArgs.baseURL).toBe("https://opencode.ai/zen/v1");
- expect(typeof zenArgs.fetch).toBe("function");
- });
-});
-
-describe("createClaudeOAuthProvider", () => {
- it("passes authToken (not apiKey) to createAnthropic for OAuth flow", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "fallback-api-key",
- baseURL: "",
- claudeCredentials: { accessToken: "oauth-access-token" },
- })("claude-opus-4-5");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.authToken).toBe("oauth-access-token");
- expect(callArgs.apiKey).toBeUndefined();
- });
-
- it("falls back to apiKey as authToken when claudeCredentials are absent", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "sk-ant-api-key",
- baseURL: "",
- })("claude-opus-4-5");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.authToken).toBe("sk-ant-api-key");
- expect(callArgs.apiKey).toBeUndefined();
- });
-
- it("includes required Claude CLI headers", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
- string,
- Record<string, string>
- >;
- expect(callArgs.headers?.["anthropic-dangerous-direct-browser-access"]).toBe("true");
- expect(callArgs.headers?.["x-app"]).toBe("cli");
- expect(callArgs.headers?.["user-agent"]).toMatch(/claude-cli/);
- });
-
- it("installs a fetch wrapper that restructures the body and stamps Claude Code session headers", async () => {
- mockCreateAnthropic.mockClear();
-
- // Capture what global fetch receives after the wrapper runs.
- const globalFetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
- const prevFetch = globalThis.fetch;
- globalThis.fetch = globalFetchMock as unknown as typeof fetch;
- try {
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-8");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as { fetch?: typeof fetch };
- expect(typeof callArgs.fetch).toBe("function");
-
- const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
- const BILLING =
- "x-anthropic-billing-header: cc_version=2.1.112.x; cc_entrypoint=sdk-cli; cch=abcde;";
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nDispatch system prompt.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: "hi there" }],
- });
-
- await callArgs.fetch?.("https://api.anthropic.com/v1/messages", {
- method: "POST",
- body,
- headers: { "content-type": "application/json" },
- });
-
- expect(globalFetchMock).toHaveBeenCalledOnce();
- const [, init] = globalFetchMock.mock.calls[0] as unknown as [unknown, RequestInit];
-
- // Body was restructured: billing isolated, third-party prompt moved to user msg.
- const sent = JSON.parse(init.body as string) as {
- system: Array<{ text: string; cache_control?: unknown }>;
- messages: Array<{ content: string }>;
- };
- expect(sent.system).toHaveLength(2);
- expect(sent.system[0]?.text).toBe(BILLING);
- expect(sent.system[0]?.cache_control).toBeUndefined();
- expect(sent.system[1]?.text).toBe(IDENTITY);
- expect(sent.messages[0]?.content).toBe("Dispatch system prompt.\n\nhi there");
-
- // Claude Code session headers were stamped.
- const headers = new Headers(init.headers);
- expect(headers.get("X-Claude-Code-Session-Id")).toBeTruthy();
- expect(headers.get("x-client-request-id")).toBeTruthy();
- } finally {
- globalThis.fetch = prevFetch;
- }
- });
-
- it("sends the anthropic-beta header so prompt-caching is honored (notes/claude-report.md Root Cause 1)", () => {
- // Without `anthropic-beta: ...,prompt-caching-scope-2026-01-05,...` the
- // Anthropic API silently ignores every `cache_control` marker we attach
- // to messages, producing a 0% cache hit rate. `@ai-sdk/anthropic` does
- // NOT inject this beta on its own — it only derives betas from tool
- // definitions — so the OAuth provider MUST set it on its config headers.
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
- string,
- Record<string, string>
- >;
- const betaHeader = callArgs.headers?.["anthropic-beta"];
- expect(betaHeader).toBeDefined();
- const betas = (betaHeader ?? "").split(",").map((b) => b.trim());
- // The load-bearing caching + oauth betas must be present.
- expect(betas).toContain("prompt-caching-scope-2026-01-05");
- expect(betas).toContain("oauth-2025-04-20");
- });
-
- it("uses default Anthropic baseURL when none provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://api.anthropic.com/v1");
- });
-
- it("uses configured baseURL when provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "https://custom.proxy.example.com/v1",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://custom.proxy.example.com/v1");
- });
-});
-
-describe("createApiKeyAnthropicProvider", () => {
- it("passes apiKey (not authToken) to createAnthropic", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "opencode-anthropic",
- apiKey: "zen-api-key",
- baseURL: "",
- })("minimax-model");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.apiKey).toBe("zen-api-key");
- expect(callArgs.authToken).toBeUndefined();
- });
-
- it("uses default OpenCode Zen baseURL when none provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "opencode-anthropic",
- apiKey: "zen-api-key",
- baseURL: "",
- })("minimax-model");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://opencode.ai/zen/go/v1");
- });
-});
diff --git a/packages/core/tests/lsp/client.test.ts b/packages/core/tests/lsp/client.test.ts
deleted file mode 100644
index 8daf8ab..0000000
--- a/packages/core/tests/lsp/client.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { spawn } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { createLspClient, type LspServerHandle } from "../../src/lsp/client.js";
-
-const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
-
-function spawnFakeServer(): LspServerHandle {
- const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
- return { process: proc as LspServerHandle["process"] };
-}
-
-const ERROR_DIAG: Diagnostic = {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
- severity: 1,
- message: "fake type error",
- source: "Fake",
-};
-
-describe("lsp/client (fake server)", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-lsp-"));
- });
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("completes the initialize handshake and forwards initializationOptions", async () => {
- const handle = spawnFakeServer();
- handle.initialization = { "luau-lsp": { platform: { type: "roblox" } } };
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
-
- const params = await client.connection.sendRequest<{ initializationOptions?: unknown }>(
- "test/get-initialize-params",
- {},
- );
- expect(params.initializationOptions).toEqual({
- "luau-lsp": { platform: { type: "roblox" } },
- });
- await client.shutdown();
- });
-
- it("opens a file and receives push diagnostics", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
-
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- const version = await client.notifyOpen(file);
- expect(version).toBe(0);
-
- // Drive a push from the fake server, then assert it lands in the map.
- await client.connection.sendRequest("test/publish-diagnostics", {
- uri: pathToFileURL(file).href,
- diagnostics: [ERROR_DIAG],
- });
- await new Promise((r) => setTimeout(r, 50));
-
- expect(client.diagnostics.get(file)?.[0]?.message).toBe("fake type error");
- await client.shutdown();
- });
-
- it("bumps the document version on re-open (didChange)", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- expect(await client.notifyOpen(file)).toBe(0);
- await writeFile(file, "local x = 2\n");
- expect(await client.notifyOpen(file)).toBe(1);
-
- const lastChange = await client.connection.sendRequest<{ textDocument?: { version?: number } }>(
- "test/get-last-change",
- {},
- );
- expect(lastChange?.textDocument?.version).toBe(1);
- await client.shutdown();
- });
-
- it("waits for pull diagnostics when the server advertises a diagnostic provider", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- // Tell the fake server (before initialize? no — it persists) to answer
- // pull requests. We configure AFTER connect; the static provider flag is
- // read at initialize, so this test exercises the dynamic registration
- // path instead.
- await client.connection.sendRequest("test/configure-pull-diagnostics", {
- registerOn: "didOpen",
- registrations: [{ id: "d1", registerOptions: { identifier: "fake" } }],
- documentDiagnostics: [ERROR_DIAG],
- });
-
- const file = join(workDir, "a.luau");
- await writeFile(file, "bad\n");
- const version = await client.notifyOpen(file);
- await client.waitForDiagnostics({ path: file, version, mode: "document" });
-
- expect(client.diagnostics.get(file)?.some((d) => d.message === "fake type error")).toBe(true);
- await client.shutdown();
- });
-
- it("request() passes through to the server (hover)", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- await client.notifyOpen(file);
- const hover = await client.request<{ contents?: { value?: string } }>("textDocument/hover", {
- textDocument: { uri: pathToFileURL(file).href },
- position: { line: 0, character: 6 },
- });
- expect(hover?.contents?.value).toBe("fake hover");
- await client.shutdown();
- });
-});
diff --git a/packages/core/tests/lsp/diagnostic.test.ts b/packages/core/tests/lsp/diagnostic.test.ts
deleted file mode 100644
index 93ffde9..0000000
--- a/packages/core/tests/lsp/diagnostic.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { pretty, report } from "../../src/lsp/diagnostic.js";
-
-function diag(partial: Partial<Diagnostic> & { message: string }): Diagnostic {
- return {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- severity: 1,
- ...partial,
- };
-}
-
-describe("lsp/diagnostic", () => {
- describe("pretty", () => {
- it("renders 1-based line/col with severity label", () => {
- const out = pretty(
- diag({
- message: "Expected number",
- range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } },
- }),
- );
- expect(out).toBe("ERROR [5:3] Expected number");
- });
-
- it("maps severities to labels", () => {
- expect(pretty(diag({ message: "w", severity: 2 }))).toMatch(/^WARN /);
- expect(pretty(diag({ message: "i", severity: 3 }))).toMatch(/^INFO /);
- expect(pretty(diag({ message: "h", severity: 4 }))).toMatch(/^HINT /);
- });
-
- it("defaults missing severity to ERROR", () => {
- expect(pretty(diag({ message: "x", severity: undefined }))).toMatch(/^ERROR /);
- });
- });
-
- describe("report", () => {
- it("returns empty string when there are no errors", () => {
- expect(report("a.luau", [])).toBe("");
- // Warnings only → still empty (errors-only).
- expect(report("a.luau", [diag({ message: "w", severity: 2 })])).toBe("");
- });
-
- it("wraps errors in a <diagnostics file> block", () => {
- const out = report("src/a.luau", [diag({ message: "boom" })]);
- expect(out).toContain('<diagnostics file="src/a.luau">');
- expect(out).toContain("ERROR [1:1] boom");
- expect(out).toContain("</diagnostics>");
- });
-
- it("filters out non-error severities", () => {
- const out = report("a.luau", [
- diag({ message: "err" }),
- diag({ message: "warn", severity: 2 }),
- ]);
- expect(out).toContain("err");
- expect(out).not.toContain("warn");
- });
-
- it("caps at 20 and notes the remainder", () => {
- const issues = Array.from({ length: 25 }, (_, i) => diag({ message: `e${i}` }));
- const out = report("a.luau", issues);
- expect(out).toContain("... and 5 more");
- expect(out).toContain("e0");
- expect(out).not.toContain("e24");
- });
- });
-});
diff --git a/packages/core/tests/lsp/luau-lsp.smoke.test.ts b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
deleted file mode 100644
index 381435b..0000000
--- a/packages/core/tests/lsp/luau-lsp.smoke.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { execSync } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { LspManager } from "../../src/lsp/manager.js";
-import { resolveServersFromConfig } from "../../src/lsp/server.js";
-
-/**
- * Opt-in smoke test against the REAL luau-lsp binary. Skipped automatically
- * (never fails CI) when `luau-lsp` is not on PATH — mirrors opencode's
- * platform-guarded launch test. When the binary IS present, it proves the
- * end-to-end path: spawn → initialize handshake → didOpen → real diagnostics.
- */
-function hasLuauLsp(): boolean {
- try {
- execSync("luau-lsp --version", { stdio: "ignore" });
- return true;
- } catch {
- return false;
- }
-}
-
-const RUN = hasLuauLsp();
-
-describe.skipIf(!RUN)("luau-lsp real-binary smoke", () => {
- let root: string;
- let manager: LspManager;
-
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "dispatch-luau-smoke-"));
- manager = new LspManager();
- });
- afterEach(async () => {
- await manager.shutdownAll();
- await rm(root, { recursive: true, force: true });
- });
-
- it("reports a real type error for a bad .luau file", async () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- initialization: {
- "luau-lsp": {
- platform: { type: "roblox" },
- diagnostics: { strictDatamodelTypes: false },
- },
- },
- },
- });
-
- const file = join(root, "bad.luau");
- await writeFile(file, 'local x: number = "not a number"\nprint(x)\n');
-
- await manager.touchFile({ file, root, servers, mode: "document" });
- const diagnostics = manager.getDiagnostics({ root, servers, file });
- const messages = (diagnostics[file] ?? []).map((d) => d.message).join("\n");
-
- expect(messages.length).toBeGreaterThan(0);
- expect(messages.toLowerCase()).toContain("number");
- }, 60_000);
-});
diff --git a/packages/core/tests/lsp/manager.test.ts b/packages/core/tests/lsp/manager.test.ts
deleted file mode 100644
index e720413..0000000
--- a/packages/core/tests/lsp/manager.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { spawn } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-
-const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
-
-function makeServer(id: string, extensions: string[]) {
- const counter = { count: 0 };
- const server: ResolvedLspServer = {
- id,
- extensions,
- spawn() {
- counter.count += 1;
- const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
- return { process: proc as never };
- },
- };
- return { server, counter };
-}
-
-describe("lsp/manager (fake server)", () => {
- let root: string;
- let manager: LspManager;
-
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "dispatch-lspmgr-"));
- manager = new LspManager();
- });
- afterEach(async () => {
- await manager.shutdownAll();
- await rm(root, { recursive: true, force: true });
- });
-
- it("hasServerForFile matches by extension", () => {
- const { server } = makeServer("fake", [".luau"]);
- expect(manager.hasServerForFile(join(root, "a.luau"), [server])).toBe(true);
- expect(manager.hasServerForFile(join(root, "a.ts"), [server])).toBe(false);
- });
-
- it("spawns lazily and reuses the client across calls", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
-
- const c1 = await manager.getClients({ file, root, servers: [server] });
- const c2 = await manager.getClients({ file, root, servers: [server] });
- expect(c1).toHaveLength(1);
- expect(c2).toHaveLength(1);
- expect(c1[0]).toBe(c2[0]);
- expect(counter.count).toBe(1);
- });
-
- it("does not spawn for a non-matching extension", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.ts");
- await writeFile(file, "const x = 1\n");
- const clients = await manager.getClients({ file, root, servers: [server] });
- expect(clients).toHaveLength(0);
- expect(counter.count).toBe(0);
- });
-
- it("touchFile + getDiagnostics surfaces a pushed diagnostic", async () => {
- const { server } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "bad code\n");
-
- await manager.touchFile({ file, root, servers: [server] });
- const [client] = await manager.getClients({ file, root, servers: [server] });
- // Drive a push through the fake server.
- const diag: Diagnostic = {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
- severity: 1,
- message: "manager error",
- };
- await client.connection.sendRequest("test/publish-diagnostics", {
- uri: pathToFileURL(file).href,
- diagnostics: [diag],
- });
- await new Promise((r) => setTimeout(r, 50));
-
- const result = manager.getDiagnostics({ root, servers: [server], file });
- expect(result[file]?.[0]?.message).toBe("manager error");
- });
-
- it("request() forwards to clients and flattens results", async () => {
- const { server } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
- await manager.touchFile({ file, root, servers: [server] });
-
- const results = await manager.request({
- file,
- root,
- servers: [server],
- method: "textDocument/definition",
- params: {
- textDocument: { uri: pathToFileURL(file).href },
- position: { line: 0, character: 6 },
- },
- });
- expect(results.length).toBeGreaterThan(0);
- });
-
- it("shutdownAll clears state so the next call respawns", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
- await manager.getClients({ file, root, servers: [server] });
- expect(counter.count).toBe(1);
- await manager.shutdownAll();
- await manager.getClients({ file, root, servers: [server] });
- expect(counter.count).toBe(2);
- });
-});
diff --git a/packages/core/tests/lsp/server.test.ts b/packages/core/tests/lsp/server.test.ts
deleted file mode 100644
index bdaf83d..0000000
--- a/packages/core/tests/lsp/server.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { resolveServersFromConfig } from "../../src/lsp/server.js";
-
-describe("lsp/server resolveServersFromConfig", () => {
- it("returns [] for undefined config", () => {
- expect(resolveServersFromConfig(undefined)).toEqual([]);
- });
-
- it("resolves a server entry with id + extensions", () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"] },
- });
- expect(servers).toHaveLength(1);
- expect(servers[0]?.id).toBe("luau-lsp");
- expect(servers[0]?.extensions).toEqual([".luau"]);
- expect(typeof servers[0]?.spawn).toBe("function");
- });
-
- it("skips disabled entries", () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"], disabled: true },
- });
- expect(servers).toEqual([]);
- });
-
- it("skips entries with empty command or extensions", () => {
- const servers = resolveServersFromConfig({
- noCommand: { command: [], extensions: [".luau"] },
- noExt: { command: ["x"], extensions: [] },
- });
- expect(servers).toEqual([]);
- });
-
- it("resolves multiple servers", () => {
- const servers = resolveServersFromConfig({
- a: { command: ["a"], extensions: [".luau"] },
- b: { command: ["b"], extensions: [".lua"] },
- });
- expect(servers.map((s) => s.id).sort()).toEqual(["a", "b"]);
- });
-});
diff --git a/packages/core/tests/models/attachments.test.ts b/packages/core/tests/models/attachments.test.ts
deleted file mode 100644
index 11a9f82..0000000
--- a/packages/core/tests/models/attachments.test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- base64ByteLength,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- validateUserContent,
-} from "../../src/models/attachments.js";
-import type { UserContentPart } from "../../src/types/index.js";
-
-/** A base64 string that decodes to exactly `bytes` bytes (no padding chars). */
-function base64OfBytes(bytes: number): string {
- // 4 base64 chars → 3 bytes. Use a multiple of 3 for clean (unpadded) output.
- const groups = Math.ceil(bytes / 3);
- return "A".repeat(groups * 4);
-}
-
-function imagePart(data: string, mediaType = "image/png"): UserContentPart {
- return { type: "attachment", mediaType, data };
-}
-
-describe("media-type predicates", () => {
- it("classifies image types", () => {
- expect(isImageMediaType("image/png")).toBe(true);
- expect(isImageMediaType("image/jpeg")).toBe(true);
- expect(isImageMediaType("image/webp")).toBe(true);
- expect(isImageMediaType("image/gif")).toBe(true);
- expect(isImageMediaType("application/pdf")).toBe(false);
- expect(isImageMediaType("image/svg+xml")).toBe(false);
- });
-
- it("classifies pdf + accepted types", () => {
- expect(isPdfMediaType("application/pdf")).toBe(true);
- expect(isPdfMediaType("image/png")).toBe(false);
- expect(isAcceptedAttachmentMediaType("image/gif")).toBe(true);
- expect(isAcceptedAttachmentMediaType("application/pdf")).toBe(true);
- expect(isAcceptedAttachmentMediaType("text/plain")).toBe(false);
- });
-});
-
-describe("base64ByteLength", () => {
- it("computes decoded length without padding", () => {
- // "AAAA" → 3 bytes.
- expect(base64ByteLength("AAAA")).toBe(3);
- });
-
- it("accounts for padding", () => {
- // "QQ==" → 1 byte ("A").
- expect(base64ByteLength("QQ==")).toBe(1);
- // "QUI=" → 2 bytes ("AB").
- expect(base64ByteLength("QUI=")).toBe(2);
- });
-
- it("tolerates a data: URI prefix and whitespace", () => {
- expect(base64ByteLength("data:image/png;base64,AAAA")).toBe(3);
- expect(base64ByteLength("AA\nAA")).toBe(3);
- });
-
- it("returns 0 for empty input", () => {
- expect(base64ByteLength("")).toBe(0);
- expect(base64ByteLength(" ")).toBe(0);
- });
-});
-
-describe("validateUserContent", () => {
- it("accepts a small image and ignores text parts", () => {
- const content: UserContentPart[] = [
- { type: "text", text: "hi" },
- imagePart(base64OfBytes(1024)),
- ];
- expect(validateUserContent(content)).toEqual({ ok: true, errors: [] });
- });
-
- it("accepts an empty / text-only content list", () => {
- expect(validateUserContent([]).ok).toBe(true);
- expect(validateUserContent([{ type: "text", text: "no files" }]).ok).toBe(true);
- });
-
- it("rejects an unsupported media type", () => {
- const res = validateUserContent([imagePart(base64OfBytes(10), "image/svg+xml")]);
- expect(res.ok).toBe(false);
- expect(res.errors[0]).toMatchObject({ code: "unsupported-type", mediaType: "image/svg+xml" });
- });
-
- it("rejects an oversized image but allows a PDF of the same size", () => {
- const big = base64OfBytes(MAX_IMAGE_BYTES + 3);
- const imgRes = validateUserContent([imagePart(big, "image/png")]);
- expect(imgRes.ok).toBe(false);
- expect(imgRes.errors.some((e) => e.code === "image-too-large")).toBe(true);
-
- // Same byte size as a PDF is fine (PDF limit is much higher).
- const pdfRes = validateUserContent([imagePart(big, "application/pdf")]);
- expect(pdfRes.ok).toBe(true);
- });
-
- it("rejects an oversized PDF", () => {
- const res = validateUserContent([
- imagePart(base64OfBytes(MAX_PDF_BYTES + 3), "application/pdf"),
- ]);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "pdf-too-large")).toBe(true);
- });
-
- it("rejects an empty attachment payload", () => {
- const res = validateUserContent([imagePart("", "image/png")]);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "empty")).toBe(true);
- });
-
- it("rejects too many attachments", () => {
- const content: UserContentPart[] = Array.from({ length: MAX_ATTACHMENTS + 1 }, () =>
- imagePart(base64OfBytes(8)),
- );
- const res = validateUserContent(content);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "too-many")).toBe(true);
- });
-
- it("rejects when the total payload exceeds the request ceiling", () => {
- // Several individually-legal PDFs that together exceed the total cap.
- const each = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 3);
- const content: UserContentPart[] = [
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- ];
- const res = validateUserContent(content);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "total-too-large")).toBe(true);
- });
-});
diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts
deleted file mode 100644
index f4bddc2..0000000
--- a/packages/core/tests/models/catalog.test.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import { existsSync, rmSync, utimesSync, writeFileSync } from "node:fs";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import {
- __resetCatalogCacheForTests,
- getModelsCatalog,
- resolveContextLimit,
- resolveModelCapabilities,
-} from "../../src/models/catalog.js";
-
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-// A trimmed models.dev-shaped catalog covering the providers we support.
-const CATALOG = {
- anthropic: {
- id: "anthropic",
- models: {
- "claude-sonnet-4-5": {
- limit: { context: 200000, output: 64000 },
- modalities: { input: ["text", "image", "pdf"], output: ["text"] },
- },
- "claude-sonnet-4-6": {
- limit: { context: 1000000, output: 64000 },
- modalities: { input: ["text", "image", "pdf"], output: ["text"] },
- },
- // A text-only model: definitively no image/pdf input.
- "text-only-model": {
- limit: { context: 100000, output: 8192 },
- modalities: { input: ["text"], output: ["text"] },
- },
- // An entry predating the modalities field → capability unknown.
- "legacy-model": { limit: { context: 100000, output: 8192 } },
- },
- },
- opencode: {
- id: "opencode",
- models: {
- "glm-4-6": {
- limit: { context: 131072, output: 8192 },
- modalities: { input: ["text", "image"], output: ["text"] },
- },
- },
- },
-};
-
-function mockFetchOnce(catalog: unknown, ok = true, status = 200) {
- const fn = vi.fn(() =>
- Promise.resolve({
- ok,
- status,
- text: () => Promise.resolve(JSON.stringify(catalog)),
- } as Response),
- );
- vi.stubGlobal("fetch", fn);
- return fn;
-}
-
-beforeEach(() => {
- __resetCatalogCacheForTests();
- if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
- delete process.env.DISPATCH_DISABLE_MODELS_FETCH;
-});
-
-afterEach(() => {
- vi.unstubAllGlobals();
- if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
-});
-
-describe("resolveContextLimit", () => {
- it("resolves a known anthropic model to its context window", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBe(1000000);
- });
-
- it("maps opencode-anthropic to the anthropic catalog, then opencode fallback", async () => {
- mockFetchOnce(CATALOG);
- // Present in the anthropic catalog.
- expect(await resolveContextLimit("opencode-anthropic", "claude-sonnet-4-5")).toBe(200000);
- // Absent in anthropic, found in the opencode gateway catalog.
- expect(await resolveContextLimit("opencode-anthropic", "glm-4-6")).toBe(131072);
- });
-
- it("returns null for an unknown model id", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("anthropic", "no-such-model")).toBeNull();
- });
-
- it("returns null for an unsupported provider (no network needed)", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("google", "gemini-2.5-pro")).toBeNull();
- expect(await resolveContextLimit("anthropic", "")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("returns null when the model has no positive context limit", async () => {
- mockFetchOnce({
- anthropic: { id: "anthropic", models: { broken: { limit: { context: 0 } } } },
- });
- expect(await resolveContextLimit("anthropic", "broken")).toBeNull();
- });
-
- it("does not throw on a malformed provider entry missing `models`", async () => {
- // A provider object without a `models` map must degrade to null, not crash.
- mockFetchOnce({ anthropic: { id: "anthropic" } });
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- });
-
- it("does not throw when limit/context fields are absent", async () => {
- mockFetchOnce({ anthropic: { id: "anthropic", models: { m: {} } } });
- expect(await resolveContextLimit("anthropic", "m")).toBeNull();
- });
-});
-
-describe("getModelsCatalog caching", () => {
- it("fetches once and serves the in-process memo on subsequent calls", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- await resolveContextLimit("anthropic", "claude-sonnet-4-5");
- await resolveContextLimit("anthropic", "claude-sonnet-4-6");
- await getModelsCatalog();
- expect(fetchFn).toHaveBeenCalledTimes(1);
- });
-
- it("reuses a fresh disk cache without re-fetching across processes", async () => {
- // Simulate another process having written a fresh cache.
- writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
- const fetchFn = vi.fn(() => Promise.reject(new Error("network should not be hit")));
- vi.stubGlobal("fetch", fetchFn);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("falls back to a STALE disk cache when the network fails", async () => {
- writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
- // Age the cache well past the TTL so the fetch path is taken.
- const old = Date.now() / 1000 - 3600;
- utimesSync(CACHE_PATH, old, old);
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(fetchFn).toHaveBeenCalledTimes(1);
- warn.mockRestore();
- });
-
- it("returns null when fetch fails and no cache exists", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- warn.mockRestore();
- });
-
- it("does not hit the network when DISPATCH_DISABLE_MODELS_FETCH is set", async () => {
- process.env.DISPATCH_DISABLE_MODELS_FETCH = "1";
- const fetchFn = vi.fn(() => Promise.reject(new Error("should not fetch")));
- vi.stubGlobal("fetch", fetchFn);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("memoizes the fallback after a failed fetch so it does not re-hit the network", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
- // First lookup triggers the (failing) fetch.
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- // Subsequent lookups within the penalty window must NOT re-fetch.
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBeNull();
- await getModelsCatalog();
- expect(fetchFn).toHaveBeenCalledTimes(1);
- warn.mockRestore();
- });
-});
-
-describe("resolveModelCapabilities", () => {
- it("reports image + pdf for a vision model", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toEqual({
- image: true,
- pdf: true,
- });
- });
-
- it("reports image-only for a model whose modalities omit pdf", async () => {
- mockFetchOnce(CATALOG);
- // glm-4-6 lists image but not pdf (resolved via the opencode fallback).
- expect(await resolveModelCapabilities("opencode-anthropic", "glm-4-6")).toEqual({
- image: true,
- pdf: false,
- });
- });
-
- it("reports a definitive no for a text-only model", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "text-only-model")).toEqual({
- image: false,
- pdf: false,
- });
- });
-
- it("returns null (unknown) for an entry without modalities", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "legacy-model")).toBeNull();
- });
-
- it("returns null (unknown) for an unknown model id", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "no-such-model")).toBeNull();
- });
-
- it("returns null for an unsupported provider without hitting the network", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("google", "gemini-2.5-pro")).toBeNull();
- expect(await resolveModelCapabilities("anthropic", "")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("returns null (unknown) when the catalog is offline with no cache", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toBeNull();
- warn.mockRestore();
- });
-});
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts
deleted file mode 100644
index 71dc00c..0000000
--- a/packages/core/tests/notifications/config.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// In-memory fake for the settings table — mounted before the module under
-// test is imported (vi.mock is hoisted).
-const fakeSettings = new Map<string, string>();
-
-vi.mock("../../src/db/settings.js", () => ({
- getSetting: vi.fn((key: string) => fakeSettings.get(key) ?? null),
- setSetting: vi.fn((key: string, value: string) => {
- fakeSettings.set(key, value);
- }),
- deleteSetting: vi.fn((key: string) => {
- fakeSettings.delete(key);
- }),
-}));
-
-const {
- clearNtfyConfig,
- defaultNtfyConfig,
- loadNtfyConfig,
- normalizeNtfyConfig,
- NTFY_CONFIG_KEY,
- redactNtfyConfig,
- saveNtfyConfig,
-} = await import("../../src/notifications/config.js");
-
-describe("defaultNtfyConfig", () => {
- it("disables notifications and ships sane per-event defaults", () => {
- const cfg = defaultNtfyConfig();
- expect(cfg.enabled).toBe(false);
- expect(cfg.topic).toBe("");
- expect(cfg.authToken).toBe("");
- expect(cfg.events["turn-completed"]).toBe(true);
- expect(cfg.events["turn-error"]).toBe(true);
- expect(cfg.events["permission-required"]).toBe(true);
- expect(cfg.events["agent-spawned"]).toBe(false);
- expect(cfg.notifySubagents).toBe(false);
- });
-});
-
-describe("normalizeNtfyConfig", () => {
- it("returns defaults for non-object input", () => {
- expect(normalizeNtfyConfig(null)).toEqual(defaultNtfyConfig());
- expect(normalizeNtfyConfig(undefined)).toEqual(defaultNtfyConfig());
- expect(normalizeNtfyConfig(42)).toEqual(defaultNtfyConfig());
- });
-
- it("fills in missing event toggles with defaults (newly-added types default OFF)", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- events: { "turn-completed": false },
- });
- expect(normalized.events["turn-completed"]).toBe(false);
- // Defaults preserved for fields the persisted blob doesn't have.
- expect(normalized.events["turn-error"]).toBe(true);
- expect(normalized.events["agent-spawned"]).toBe(false);
- });
-
- it("ignores extraneous fields and wrong-typed values", () => {
- const normalized = normalizeNtfyConfig({
- enabled: "yes", // wrong type ⇒ default
- topic: 42, // wrong type ⇒ default
- authToken: null, // wrong type ⇒ default
- events: { "turn-completed": "no", bogus: true },
- extra: "ignored",
- });
- expect(normalized.enabled).toBe(false);
- expect(normalized.topic).toBe("");
- expect(normalized.authToken).toBe("");
- expect(normalized.events["turn-completed"]).toBe(true); // default kept
- expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined();
- });
-});
-
-describe("normalizeNtfyConfig — notifySubagents", () => {
- it("defaults notifySubagents to false when absent", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- });
- expect(normalized.notifySubagents).toBe(false);
- });
-
- it("respects an explicit notifySubagents=true", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- notifySubagents: true,
- });
- expect(normalized.notifySubagents).toBe(true);
- });
-
- it("falls back to default when notifySubagents is wrong-typed", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- notifySubagents: "yes" as unknown,
- });
- expect(normalized.notifySubagents).toBe(false);
- });
-});
-
-describe("load/save round-trip", () => {
- beforeEach(() => {
- fakeSettings.clear();
- });
-
- it("returns defaults when nothing is persisted", () => {
- expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
- });
-
- it("round-trips a complete config", () => {
- const cfg = {
- enabled: true,
- topic: "https://ntfy.sh/team",
- authToken: "tk_abc",
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- notifySubagents: true,
- } as const;
- saveNtfyConfig({ ...cfg });
- const loaded = loadNtfyConfig();
- expect(loaded).toEqual(cfg);
- // Persisted as a JSON string under the documented key.
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
- });
-
- it("returns defaults when stored JSON is corrupt", () => {
- fakeSettings.set(NTFY_CONFIG_KEY, "{ not json");
- expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
- });
-
- it("clearNtfyConfig removes the persisted entry", () => {
- saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topic: "https://ntfy.sh/x" });
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
- clearNtfyConfig();
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false);
- });
-});
-
-describe("redactNtfyConfig", () => {
- it("strips authToken and surfaces a hasAuthToken flag", () => {
- const cfg = { ...defaultNtfyConfig(), authToken: "tk_secret" };
- const redacted = redactNtfyConfig(cfg);
- expect(redacted.authToken).toBe("");
- expect(redacted.hasAuthToken).toBe(true);
- });
-
- it("hasAuthToken is false for blank tokens", () => {
- expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: "" }).hasAuthToken).toBe(false);
- expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: " " }).hasAuthToken).toBe(false);
- });
-});
diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts
deleted file mode 100644
index c2faba6..0000000
--- a/packages/core/tests/notifications/dispatcher.test.ts
+++ /dev/null
@@ -1,461 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
-
-// The dispatcher imports `loadNtfyConfig` from config.ts, which transitively
-// pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can
-// load this file. All tests inject `loadConfig` explicitly, so the real
-// settings table is never read.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => ({
- query: () => ({ get: () => null, run: () => {} }),
- run: () => {},
- })),
-}));
-
-const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js");
-
-function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
- return {
- enabled: true,
- topic: "test-topic",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- // Default to true in the test config so existing tests (which never
- // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc.
- // Tests of the new subagent gating override this explicitly.
- notifySubagents: true,
- ...overrides,
- };
-}
-
-interface FakeAgentSource {
- onEvent(
- listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void,
- ): () => void;
- emit(event: { type: string; tabId: string; [k: string]: unknown }): void;
-}
-
-function makeAgentSource(): FakeAgentSource {
- let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null;
- return {
- onEvent(listener) {
- l = listener;
- return () => {
- l = null;
- };
- },
- emit(event) {
- l?.(event);
- },
- };
-}
-
-interface FakePermissionSource {
- onPromptAdded(
- listener: (prompt: { id: string; permission: string; description: string }) => void,
- ): () => void;
- emit(prompt: { id: string; permission: string; description: string }): void;
-}
-
-function makePermissionSource(): FakePermissionSource {
- let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null;
- return {
- onPromptAdded(listener) {
- l = listener;
- return () => {
- l = null;
- };
- },
- emit(p) {
- l?.(p);
- },
- };
-}
-
-// Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)`
-// has a chance to settle before assertions.
-async function flush(): Promise<void> {
- await Promise.resolve();
- await Promise.resolve();
-}
-
-describe("NotificationDispatcher.notify", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("does not send when master switch is disabled", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ enabled: false }),
- send,
- });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("does not send when per-event-type toggle is off", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () =>
- makeConfig({
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- }),
- send,
- });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("sends when enabled and toggle is on", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("does not throw or block when the transport rejects", async () => {
- const send = vi.fn(async () => {
- throw new Error("boom");
- });
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow();
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalled();
- });
-
- it("dedupes events with the same dedupeKey within the window", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig(),
- send,
- dedupeWindowMs: 1000,
- });
- const event: NotificationEvent = {
- type: "permission-required",
- title: "p",
- message: "p",
- dedupeKey: "permission:42",
- };
- d.notify(event);
- d.notify(event);
- d.notify(event);
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("does not dedupe events without a dedupeKey", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).toHaveBeenCalledTimes(2);
- });
-});
-
-describe("NotificationDispatcher.attachToAgentManager", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("maps `done` → turn-completed (with tab title in the body)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig(),
- send,
- getTabTitle: (id) => (id === "tab-1" ? "My chat" : null),
- });
- d.attachToAgentManager(source);
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("turn-completed");
- expect(event.title).toContain("My chat");
- expect(event.tabId).toBe("tab-1");
- });
-
- it("maps `error` → turn-error and includes the error text", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("turn-error");
- expect(event.message).toContain("Rate limit");
- expect(event.message).toContain("429");
- });
-
- it("ignores `status` events (would spam every transition)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- source.emit({ type: "status", tabId: "tab-1", status: "running" });
- source.emit({ type: "status", tabId: "tab-1", status: "idle" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
-
- // Manual "new tab" with no agent slug ⇒ no notification.
- source.emit({
- type: "tab-created",
- tabId: "tab-1",
- id: "tab-1",
- title: "New Tab",
- parentTabId: null,
- agentSlug: null,
- });
- // Subagent (has a parent) ⇒ no notification.
- source.emit({
- type: "tab-created",
- tabId: "tab-2",
- id: "tab-2",
- title: "Subagent",
- parentTabId: "tab-1",
- agentSlug: "researcher",
- });
- // Top-level user agent ⇒ notify.
- source.emit({
- type: "tab-created",
- tabId: "tab-3",
- id: "tab-3",
- title: "Refactor auth code",
- parentTabId: null,
- agentSlug: "engineer",
- });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("agent-spawned");
- expect(event.message).toBe("Refactor auth code");
- expect(event.title).toContain("engineer");
- });
-
- it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () =>
- makeConfig({
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- }),
- send,
- });
- d.attachToAgentManager(source);
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-});
-
-describe("NotificationDispatcher.attachToPermissionManager", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("notifies once per unique prompt id (dedupes re-emits)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makePermissionSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToPermissionManager(source);
-
- source.emit({ id: "1", permission: "bash", description: "Run git status" });
- source.emit({ id: "1", permission: "bash", description: "Run git status" });
- source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" });
- await flush();
- expect(send).toHaveBeenCalledTimes(2);
- const events = send.mock.calls.map((c) => c[1] as NotificationEvent);
- expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]);
- expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true);
- });
-});
-
-describe("NotificationDispatcher.dispose", () => {
- it("releases attached subscriptions", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- d.dispose();
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-});
-
-describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- const parents = new Map<string, string | null>([
- ["top-level", null],
- ["subagent", "top-level"],
- ]);
- const getTabParentId = (id: string): string | null | undefined => parents.get(id);
-
- it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
- source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
- });
-
- it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "error", tabId: "subagent", error: "boom" });
- source.emit({ type: "error", tabId: "top-level", error: "boom" });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
- });
-
- it("still notifies subagents when notifySubagents=true", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: true }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
- source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(2);
- });
-
- it("does NOT gate permission-required (subagents must still get human input)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const psource = makePermissionSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToPermissionManager(psource);
-
- psource.emit({ id: "p1", permission: "bash", description: "git status" });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required");
- });
-
- it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- // intentionally NO getTabParentId
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } });
- await flush();
-
- // Without a lookup, the dispatcher can't prove this is a subagent; it
- // must err on the side of notifying so legitimate top-level events
- // aren't silently dropped.
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("falls back to notifying when getTabParentId throws or returns undefined", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId: () => {
- throw new Error("db unavailable");
- },
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
-
- const send2 = vi.fn(async () => ({ ok: true }));
- const source2 = makeAgentSource();
- const d2 = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send: send2,
- getTabParentId: () => undefined,
- });
- d2.attachToAgentManager(source2);
- source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send2).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts
deleted file mode 100644
index 5f14a60..0000000
--- a/packages/core/tests/notifications/ntfy.test.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { buildNtfyUrl, NTFY_BASE_URL, sendNtfy } from "../../src/notifications/ntfy.js";
-import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
-
-function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
- return {
- enabled: true,
- topic: "my-topic",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- notifySubagents: false,
- ...overrides,
- };
-}
-
-function makeEvent(overrides: Partial<NotificationEvent> = {}): NotificationEvent {
- return {
- type: "turn-completed",
- title: "Done",
- message: "all good",
- ...overrides,
- };
-}
-
-function makeFetch(
- response: Partial<{ ok: boolean; status: number; statusText: string; body: string }> = {},
-) {
- const fetchImpl = vi.fn(async () => ({
- ok: response.ok ?? true,
- status: response.status ?? 200,
- statusText: response.statusText ?? "OK",
- text: async () => response.body ?? "",
- }));
- return fetchImpl;
-}
-
-describe("buildNtfyUrl", () => {
- it("prefixes the public ntfy.sh host", () => {
- expect(buildNtfyUrl("my-topic")).toBe(`${NTFY_BASE_URL}/my-topic`);
- });
-
- it("trims surrounding whitespace", () => {
- expect(buildNtfyUrl(" hello ")).toBe(`${NTFY_BASE_URL}/hello`);
- });
-
- it("URL-encodes the topic so any string yields a valid URL", () => {
- // Spaces, slashes, unicode — all preserved as encoded bytes; the ntfy
- // server is the final authority on what it accepts.
- expect(buildNtfyUrl("has space")).toBe(`${NTFY_BASE_URL}/has%20space`);
- expect(buildNtfyUrl("a/b")).toBe(`${NTFY_BASE_URL}/a%2Fb`);
- expect(buildNtfyUrl("日本語")).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
- });
-});
-
-describe("sendNtfy", () => {
- it("POSTs to https://ntfy.sh/<topic> with Title/Priority/Tags/Content-Type headers and body", async () => {
- const fetchImpl = makeFetch();
- const result = await sendNtfy(
- makeConfig(),
- makeEvent({ title: "Hello", message: "World", tags: ["bell"], priority: 4 }),
- fetchImpl,
- );
- expect(result.ok).toBe(true);
- expect(fetchImpl).toHaveBeenCalledTimes(1);
- const [url, init] = fetchImpl.mock.calls[0];
- expect(url).toBe(`${NTFY_BASE_URL}/my-topic`);
- expect(init.method).toBe("POST");
- expect(init.headers.Title).toBe("Hello");
- expect(init.headers.Priority).toBe("4");
- expect(init.headers.Tags).toBe("bell");
- expect(init.headers["Content-Type"]).toMatch(/text\/plain/);
- expect(init.body).toBe("World");
- });
-
- it("accepts arbitrary topic strings without a client-side pattern check", async () => {
- const fetchImpl = makeFetch();
- // Things the old validator would have rejected — dots, spaces, unicode,
- // a single-word "any topic". All should POST and let ntfy decide.
- await sendNtfy(makeConfig({ topic: "release.notes" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "with space" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "Any Topic Whatsoever" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "日本語" }), makeEvent(), fetchImpl);
- expect(fetchImpl).toHaveBeenCalledTimes(4);
- expect(fetchImpl.mock.calls[0][0]).toBe(`${NTFY_BASE_URL}/release.notes`);
- expect(fetchImpl.mock.calls[1][0]).toBe(`${NTFY_BASE_URL}/with%20space`);
- expect(fetchImpl.mock.calls[2][0]).toBe(`${NTFY_BASE_URL}/Any%20Topic%20Whatsoever`);
- expect(fetchImpl.mock.calls[3][0]).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
- });
-
- it("uses per-event-type defaults for priority and tags", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Priority).toBe("4"); // NTFY_DEFAULT_PRIORITIES["turn-error"]
- expect(init.headers.Tags).toBe("rotating_light");
- });
-
- it("attaches Authorization header with Bearer prefix when authToken is a bare token", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Authorization).toBe("Bearer tk_secret");
- });
-
- it("passes a pre-prefixed Authorization value (Basic, custom schemes) through verbatim", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: "Basic dXNlcjpwYXNz" }), makeEvent(), fetchImpl);
- expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe("Basic dXNlcjpwYXNz");
-
- const fetchImpl2 = makeFetch();
- await sendNtfy(makeConfig({ authToken: "Bearer already_prefixed" }), makeEvent(), fetchImpl2);
- expect(fetchImpl2.mock.calls[0][1].headers.Authorization).toBe("Bearer already_prefixed");
- });
-
- it("omits Authorization when authToken is blank", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Authorization).toBeUndefined();
- });
-
- it("attaches Click header when clickUrl is set", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ clickUrl: "https://example.com/tab/abc" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Click).toBe("https://example.com/tab/abc");
- });
-
- it("sanitizes Click header (CR/LF injection guard)", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(
- makeConfig(),
- makeEvent({ clickUrl: "https://example.com/\r\nInjected: yes" }),
- fetchImpl,
- );
- const v = fetchImpl.mock.calls[0][1].headers.Click;
- expect(v).not.toContain("\n");
- expect(v).not.toContain("\r");
- });
-
- it("appends short tab tag when tabId is set", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(
- makeConfig(),
- makeEvent({ tabId: "abcdef0123456789", tags: ["bell"] }),
- fetchImpl,
- );
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Tags).toBe("bell,tab-abcdef01");
- });
-
- it("strips CR/LF/control chars from header values (injection guard)", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ title: "line1\r\nInjected: yes" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Title).not.toContain("\n");
- expect(init.headers.Title).not.toContain("\r");
- expect(init.headers.Title).toBe("line1 Injected: yes");
- });
-
- it("returns ok:false when notifications are disabled", async () => {
- const fetchImpl = makeFetch();
- const result = await sendNtfy(makeConfig({ enabled: false }), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/disabled/);
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it("returns ok:false when topic is empty / whitespace, without calling fetch", async () => {
- const fetchImpl = makeFetch();
- const empty = await sendNtfy(makeConfig({ topic: "" }), makeEvent(), fetchImpl);
- expect(empty.ok).toBe(false);
- expect(empty.error).toMatch(/required/i);
-
- const ws = await sendNtfy(makeConfig({ topic: " " }), makeEvent(), fetchImpl);
- expect(ws.ok).toBe(false);
- expect(ws.error).toMatch(/required/i);
-
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it("returns ok:false with status on non-2xx response", async () => {
- const fetchImpl = makeFetch({ ok: false, status: 403, statusText: "Forbidden", body: "nope" });
- const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.status).toBe(403);
- expect(result.error).toMatch(/403/);
- expect(result.error).toMatch(/nope/);
- });
-
- it("returns ok:false with error message on fetch throwing", async () => {
- const fetchImpl = vi.fn(async () => {
- throw new Error("ECONNREFUSED");
- });
- const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/ECONNREFUSED/);
- });
-});
diff --git a/packages/core/tests/permission/evaluate.test.ts b/packages/core/tests/permission/evaluate.test.ts
deleted file mode 100644
index c8f4541..0000000
--- a/packages/core/tests/permission/evaluate.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { evaluate } from "../../src/permission/evaluate.js";
-import type { Ruleset } from "../../src/permission/index.js";
-
-describe("evaluate", () => {
- it("returns default ask when no rules match", () => {
- const result = evaluate("bash", "ls -la");
- expect(result.action).toBe("ask");
- expect(result.permission).toBe("bash");
- expect(result.pattern).toBe("ls -la");
- });
-
- it("returns allow when matching rule is allow", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "ls *", action: "allow" }];
- const result = evaluate("bash", "ls -la", rules);
- expect(result.action).toBe("allow");
- });
-
- it("returns deny when matching rule is deny", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("deny");
- });
-
- it("last-match-wins: later deny overrides earlier allow", () => {
- const rules: Ruleset = [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm *", action: "deny" },
- ];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("deny");
- });
-
- it("last-match-wins: later allow overrides earlier deny", () => {
- const rules: Ruleset = [
- { permission: "bash", pattern: "rm *", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("allow");
- });
-
- it("matches permission wildcard", () => {
- const rules: Ruleset = [{ permission: "*", pattern: "*", action: "allow" }];
- const result = evaluate("read", "anything", rules);
- expect(result.action).toBe("allow");
- });
-
- it("multiple rulesets are concatenated, last match wins across rulesets", () => {
- const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }];
- const overrideRules: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
- const result = evaluate("bash", "git status", baseRules, overrideRules);
- expect(result.action).toBe("allow");
- });
-
- it("second ruleset can deny what first ruleset allows", () => {
- const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const overrideRules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
- const result = evaluate("bash", "rm -rf /", baseRules, overrideRules);
- expect(result.action).toBe("deny");
- });
-
- it("non-matching permission returns default ask", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const result = evaluate("read", "/some/path", rules);
- expect(result.action).toBe("ask");
- });
-});
diff --git a/packages/core/tests/permission/service.test.ts b/packages/core/tests/permission/service.test.ts
deleted file mode 100644
index d1b39d9..0000000
--- a/packages/core/tests/permission/service.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { PermissionRequest, Ruleset } from "../../src/permission/index.js";
-import { PermissionService } from "../../src/permission/service.js";
-
-function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
- return {
- permission: "bash",
- patterns: ["git *"],
- always: ["git status"],
- description: "Run git status",
- metadata: {},
- ...overrides,
- };
-}
-
-describe("PermissionService", () => {
- it("resolves immediately with 'once' when rule is allow", async () => {
- const svc = new PermissionService();
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const reply = await svc.ask(makeRequest(), [rules]);
- expect(reply).toBe("once");
- });
-
- it("rejects immediately when rule is deny", async () => {
- const svc = new PermissionService();
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
- await expect(svc.ask(makeRequest(), [rules])).rejects.toThrow("Permission denied");
- });
-
- it("creates pending request when rule is ask", () => {
- const svc = new PermissionService();
- svc.ask(makeRequest(), []);
- expect(svc.getPending()).toHaveLength(1);
- });
-
- it("reply 'once' resolves the specific pending request", async () => {
- const svc = new PermissionService();
- const promise = svc.ask(makeRequest(), []);
- const pending = svc.getPending();
- expect(pending).toHaveLength(1);
- svc.reply(pending[0].id, "once");
- const result = await promise;
- expect(result).toBe("once");
- expect(svc.getPending()).toHaveLength(0);
- });
-
- it("reply 'always' adds approved rules and resolves", async () => {
- const svc = new PermissionService();
- const promise = svc.ask(makeRequest({ patterns: ["git *"] }), []);
- const pending = svc.getPending();
- svc.reply(pending[0].id, "always");
- const result = await promise;
- expect(result).toBe("always");
-
- // Now the same permission should be immediately allowed
- const reply2 = await svc.ask(makeRequest({ always: ["git commit"] }), []);
- expect(reply2).toBe("once");
- });
-
- it("reply 'reject' rejects all pending requests (cascade)", async () => {
- const svc = new PermissionService();
- const p1 = svc.ask(makeRequest(), []);
- const p2 = svc.ask(makeRequest({ permission: "read" }), []);
-
- const pending = svc.getPending();
- expect(pending).toHaveLength(2);
-
- // Reject using the first id — should cascade to all
- svc.reply(pending[0].id, "reject");
-
- await expect(p1).rejects.toThrow("Permission rejected");
- await expect(p2).rejects.toThrow("Permission rejected");
- expect(svc.getPending()).toHaveLength(0);
- });
-
- it("approved rules override config rulesets", async () => {
- const svc = new PermissionService();
- svc.approve([{ permission: "bash", pattern: "git *", action: "allow" }]);
-
- // Config says deny, but approved says allow — approved wins (last)
- const configRules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
- const reply = await svc.ask(makeRequest({ always: ["git status"] }), [configRules]);
- expect(reply).toBe("once");
- });
-
- it("getPending returns all pending requests with id and request", () => {
- const svc = new PermissionService();
- const req = makeRequest();
- svc.ask(req, []);
- const pending = svc.getPending();
- expect(pending).toHaveLength(1);
- expect(pending[0].id).toBeDefined();
- expect(pending[0].request.permission).toBe("bash");
- });
-});
diff --git a/packages/core/tests/permission/wildcard.test.ts b/packages/core/tests/permission/wildcard.test.ts
deleted file mode 100644
index 8fa30a7..0000000
--- a/packages/core/tests/permission/wildcard.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { Wildcard } from "../../src/permission/wildcard.js";
-
-describe("Wildcard.match", () => {
- it("matches exact string", () => {
- expect(Wildcard.match("bash", "bash")).toBe(true);
- expect(Wildcard.match("bash", "read")).toBe(false);
- });
-
- it("matches * wildcard (any characters)", () => {
- expect(Wildcard.match("*", "bash")).toBe(true);
- expect(Wildcard.match("*", "anything")).toBe(true);
- expect(Wildcard.match("ba*", "bash")).toBe(true);
- expect(Wildcard.match("ba*", "ba")).toBe(true);
- expect(Wildcard.match("ba*", "read")).toBe(false);
- });
-
- it("matches ? wildcard (single character)", () => {
- expect(Wildcard.match("ba?h", "bash")).toBe(true);
- expect(Wildcard.match("ba?h", "bath")).toBe(true);
- expect(Wildcard.match("ba?h", "baXXh")).toBe(false);
- expect(Wildcard.match("?", "a")).toBe(true);
- expect(Wildcard.match("?", "ab")).toBe(false);
- });
-
- it("matches nested * patterns with path-like strings", () => {
- expect(Wildcard.match("/home/*", "/home/user")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/home/user/file.txt")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/home/user/subdir/file.txt")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/tmp/user/file.txt")).toBe(false);
- });
-
- it("escapes regex special characters in pattern", () => {
- expect(Wildcard.match("git add .", "git add .")).toBe(true);
- expect(Wildcard.match("git add .", "git add X")).toBe(false);
- expect(Wildcard.match("foo(bar)", "foo(bar)")).toBe(true);
- expect(Wildcard.match("foo(bar)", "fooXbar")).toBe(false);
- });
-
- it("is case-sensitive", () => {
- expect(Wildcard.match("Bash", "bash")).toBe(false);
- expect(Wildcard.match("BASH", "BASH")).toBe(true);
- });
-
- it("handles empty pattern and value", () => {
- expect(Wildcard.match("", "")).toBe(true);
- expect(Wildcard.match("", "x")).toBe(false);
- expect(Wildcard.match("*", "")).toBe(true);
- });
-});
diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts
deleted file mode 100644
index a01a6a5..0000000
--- a/packages/core/tests/tools/bash-arity.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { prefix } from "../../src/tools/bash-arity.js";
-
-describe("BashArity.prefix", () => {
- it("returns arity-2 prefix for known command 'git'", () => {
- expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
- });
-
- it("returns arity-3 prefix for npm", () => {
- expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
- });
-
- it("returns arity-2 prefix for bun", () => {
- expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]);
- });
-
- it("returns just the command for unknown command", () => {
- expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]);
- });
-
- it("returns empty array for empty tokens", () => {
- expect(prefix([])).toEqual([]);
- });
-
- it("handles single token for unknown command", () => {
- expect(prefix(["ls"])).toEqual(["ls"]);
- });
-
- it("handles git with fewer tokens than arity", () => {
- expect(prefix(["git"])).toEqual(["git"]);
- });
-
- it("handles case-insensitive matching", () => {
- expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]);
- });
-});
diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts
deleted file mode 100644
index 643e30e..0000000
--- a/packages/core/tests/tools/key-usage.test.ts
+++ /dev/null
@@ -1,317 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// The tool imports `getAccountUsageWithSource` from `claude.ts`, which
-// transitively imports `db/index.js` (top-level `import { Database } from
-// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject
-// stub fetchers and never hit the real fetchers/DB, so stubbing the db module
-// is enough to let the import chain resolve.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js";
-import type { OpencodeUsageReport } from "../../src/credentials/opencode.js";
-import {
- createKeyUsageTool,
- formatKeyUsage,
- type KeyUsageCallbacks,
-} from "../../src/tools/key-usage.js";
-import type { KeyDefinition, KeyState } from "../../src/types/index.js";
-
-// ─── Builders ─────────────────────────────────────────────────
-
-function keyState(
- def: Partial<KeyDefinition> & { id: string; provider: string },
- overrides: Partial<Omit<KeyState, "definition">> = {},
-): KeyState {
- return {
- definition: { base_url: "https://example.test", ...def },
- status: "active",
- ...overrides,
- };
-}
-
-function account(id: string, source = `/creds/${id}.json`): ClaudeAccount {
- return {
- id,
- label: id,
- source,
- credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 },
- };
-}
-
-/** Build the tool with explicit stub fetchers — no network, no DB. */
-function buildTool(opts: {
- keys: KeyState[];
- accounts?: ClaudeAccount[];
- anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}) {
- const callbacks: KeyUsageCallbacks = {
- listKeys: () => opts.keys,
- listClaudeAccounts: () => opts.accounts ?? [],
- fetchAnthropicUsage: opts.anthropic ?? (async () => null),
- fetchOpencodeUsage: opts.opencode ?? (async () => null),
- };
- return createKeyUsageTool(callbacks);
-}
-
-const HOUR = 3_600_000;
-
-describe("key_usage tool", () => {
- it("reports all keys when no key_id is given", async () => {
- const reset5h = Date.now() + 2 * HOUR;
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max", "/creds/max.json")],
- anthropic: async () => ({
- source: "live",
- report: {
- fiveHour: { utilization: 0.25, resetsAt: reset5h },
- sevenDay: { utilization: 0.6 },
- },
- }),
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.4 },
- monthly: { utilization: 0.7 },
- }),
- });
-
- const out = await tool.execute({});
-
- // Both keys present with providers.
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).toContain("[opencode-1] provider: opencode-go");
- // Remaining = (1 - utilization) * 100.
- expect(out).toContain("5-hour: 75% remaining");
- expect(out).toContain("week: 40% remaining");
- expect(out).toContain("5-hour: 90% remaining");
- expect(out).toContain("week: 60% remaining");
- expect(out).toContain("month: 30% remaining");
- expect(out).toContain("data: live (fetched just now)");
- });
-
- it("filters to a single key when key_id is given and does not fetch others", async () => {
- const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } }));
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.2 } },
- }),
- opencode: opencodeFetch,
- });
-
- const out = await tool.execute({ key_id: "claude-max" });
-
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).not.toContain("opencode-1");
- expect(opencodeFetch).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error for an unknown key_id", async () => {
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- });
-
- const out = await tool.execute({ key_id: "nope" });
-
- expect(out).toContain('no key found with id "nope"');
- expect(out).toContain("claude-max");
- expect(out).toContain("opencode-1");
- });
-
- it("reports cached data with the source's last-fetched timestamp", async () => {
- const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5);
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "cache",
- cachedAt,
- report: { fiveHour: { utilization: 0.5 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z");
- expect(out).toContain("5-hour: 50% remaining");
- });
-
- it("omits the month window for anthropic (no monthly bucket)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour:");
- expect(out).toContain("week:");
- expect(out).not.toContain("month:");
- });
-
- it("includes the month window for opencode-go", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.2 },
- monthly: { utilization: 0.3 },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("month: 70% remaining");
- });
-
- it("surfaces exhausted status with the last error", async () => {
- const exhaustedAt = Date.now() - HOUR;
- const tool = buildTool({
- keys: [
- keyState(
- { id: "opencode-1", provider: "opencode-go" },
- { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt },
- ),
- ],
- opencode: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("status: EXHAUSTED");
- expect(out).toContain("last error: 429 rate limit exceeded");
- });
-
- it("flags providers without usage support", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "gem", provider: "google" })],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("[gem] provider: google");
- expect(out).toContain("not supported");
- });
-
- it("reports unavailable when a supported provider returns no usage", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- expect(out).toContain("no cached usage");
- });
-
- it("reports unavailable for anthropic keys with no account credentials", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("no Claude account credentials available");
- });
-
- it("treats a fetcher that throws as unavailable (does not crash)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => {
- throw new Error("network down");
- },
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- });
-
- it("reports when no keys are configured at all", async () => {
- const tool = buildTool({ keys: [] });
- const out = await tool.execute({});
- expect(out).toBe("No API keys are configured.");
- });
-
- it("clamps out-of-range utilization to 0–100%", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining
- weekly: { utilization: -0.5 }, // negative → 100% remaining
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour: 0% remaining");
- expect(out).toContain("week: 100% remaining");
- });
-});
-
-describe("formatKeyUsage (pure)", () => {
- const now = Date.UTC(2025, 5, 1, 12, 0, 0);
-
- it("formats reset timestamps with ISO + relative time", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "claude-max",
- provider: "anthropic",
- status: "active",
- dataSource: "live",
- windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }],
- },
- ],
- now,
- );
-
- expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)");
- });
-
- it("renders a past reset/exhaustion time as 'ago'", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "opencode-1",
- provider: "opencode-go",
- status: "exhausted",
- exhaustedAt: now - 2 * HOUR,
- lastError: "boom",
- windows: [],
- },
- ],
- now,
- );
-
- expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)");
- expect(out).toContain("last error: boom");
- });
-
- it("returns a friendly message when no entries match", () => {
- expect(formatKeyUsage([], now)).toBe("No API keys matched.");
- });
-});
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
deleted file mode 100644
index f371717..0000000
--- a/packages/core/tests/tools/list-files.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createListFilesTool } from "../../src/tools/list-files.js";
-
-describe("list_files tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("lists directory contents", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "file1.txt"), "a");
- await writeFile(join(workDir, "file2.txt"), "b");
- await mkdir(join(workDir, "subdir"));
- const result = await tool.execute({ path: "." });
- expect(result).toContain("file1.txt");
- expect(result).toContain("file2.txt");
- expect(result).toContain("subdir/");
- });
-
- it("defaults to current directory when path is undefined", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "hi");
- const result = await tool.execute({});
- expect(result).toContain("hello.txt");
- });
-
- it("blocks path traversal", async () => {
- const tool = createListFilesTool(workDir);
- const result = await tool.execute({ path: "../" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, relPath))` — when relPath
- // is absolute, `join` does NOT short-circuit. The old code silently
- // rewrote `/some/path` to `<workdir>/some/path` and either returned an
- // ENOENT-style error or, worse, listed an unrelated path. After the fix,
- // absolute paths resolve to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("lists an absolute path that lives under the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "alpha.txt"), "a");
- await writeFile(join(workDir, "beta.txt"), "b");
- const result = await tool.execute({ path: workDir });
- expect(result).toContain("alpha.txt");
- expect(result).toContain("beta.txt");
- // "Error listing files" would indicate the path was mangled into a
- // non-existent location.
- expect(result).not.toMatch(/error listing/i);
- });
-
- it("rejects absolute paths outside the workdir with the workdir error (not a generic ENOENT)", async () => {
- const tool = createListFilesTool(workDir);
- // Use a tmpdir path that's definitely not under workDir. Under the
- // bug, this got rewritten to `<workdir>/tmp/...` and produced an
- // `Error listing files` ENOENT message instead of the workdir error.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}`);
- const result = await tool.execute({ path: evilPath });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // A directory symlink inside the workdir pointing to an external
- // directory is the classic escape vector for a `ls` style tool.
- // `canonicalize` must resolve the symlink so the listing is denied.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- await writeFile(join(externalDir, "secret.txt"), "secret");
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks listing through a symlinked directory that escapes the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await symlink(externalDir, join(workDir, "peek"));
- const result = await tool.execute({ path: "peek" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("secret.txt");
- });
- });
-});
diff --git a/packages/core/tests/tools/lsp-tool.test.ts b/packages/core/tests/tools/lsp-tool.test.ts
deleted file mode 100644
index 7f26522..0000000
--- a/packages/core/tests/tools/lsp-tool.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import type { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-import { createLspTool, type LspToolContext } from "../../src/tools/lsp.js";
-
-const SERVER: ResolvedLspServer = {
- id: "luau-lsp",
- extensions: [".luau"],
- spawn: () => ({ process: {} as never }),
-};
-
-function makeManager(overrides: Partial<LspManager> = {}): LspManager {
- return {
- hasServerForFile: vi.fn(() => true),
- touchFile: vi.fn(async () => {}),
- getDiagnostics: vi.fn(() => ({})),
- request: vi.fn(async () => []),
- getClients: vi.fn(async () => []),
- shutdownAll: vi.fn(async () => {}),
- ...overrides,
- } as unknown as LspManager;
-}
-
-function ctx(manager: LspManager, servers = [SERVER]): () => LspToolContext {
- return () => ({ manager, workingDirectory: "/work", servers });
-}
-
-describe("createLspTool", () => {
- it("exposes the expected schema/name", () => {
- const tool = createLspTool(ctx(makeManager()));
- expect(tool.name).toBe("lsp");
- expect(tool.description).toMatch(/luau-lsp/i);
- });
-
- it("errors when no servers are configured", async () => {
- const tool = createLspTool(ctx(makeManager(), []));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/no LSP servers are configured/i);
- });
-
- it("errors when no server matches the file", async () => {
- const manager = makeManager({ hasServerForFile: vi.fn(() => false) as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.ts" });
- expect(out).toMatch(/no configured LSP server matches/i);
- });
-
- it("diagnostics: touches the file then reports errors", async () => {
- const touchFile = vi.fn(async () => {});
- const getDiagnostics = vi.fn(() => ({
- "/work/a.luau": [
- {
- range: { start: { line: 2, character: 1 }, end: { line: 2, character: 9 } },
- severity: 1,
- message: "bad type",
- },
- ],
- }));
- const manager = makeManager({
- touchFile: touchFile as never,
- getDiagnostics: getDiagnostics as never,
- });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(touchFile).toHaveBeenCalledOnce();
- expect(out).toContain("ERROR [3:2] bad type");
- });
-
- it("diagnostics: reports clean when no errors", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/No errors reported/i);
- });
-
- it("hover: requires line and character", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "hover", path: "a.luau" });
- expect(out).toMatch(/requires both 'line' and 'character'/i);
- });
-
- it("hover: converts 1-based coords to 0-based on the wire", async () => {
- const request = vi.fn(async () => [{ contents: "hi" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "hover", path: "a.luau", line: 5, character: 3 });
- expect(request).toHaveBeenCalledOnce();
- const arg = request.mock.calls[0]?.[0] as { method: string; params: { position: unknown } };
- expect(arg.method).toBe("textDocument/hover");
- expect(arg.params.position).toEqual({ line: 4, character: 2 });
- });
-
- it("references: includes declaration context", async () => {
- const request = vi.fn(async () => []);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "references", path: "a.luau", line: 1, character: 1 });
- const arg = request.mock.calls[0]?.[0] as { params: { context?: unknown } };
- expect(arg.params.context).toEqual({ includeDeclaration: true });
- });
-
- it("documentSymbol: does not require a position", async () => {
- const request = vi.fn(async () => [{ name: "foo" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "documentSymbol", path: "a.luau" });
- const arg = request.mock.calls[0]?.[0] as { method: string };
- expect(arg.method).toBe("textDocument/documentSymbol");
- expect(out).toContain("foo");
- });
-});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
deleted file mode 100644
index 90165d8..0000000
--- a/packages/core/tests/tools/read-file.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { randomBytes } from "node:crypto";
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createReadFileTool } from "../../src/tools/read-file.js";
-import { SPILL_ROOT } from "../../src/tools/truncate.js";
-
-describe("read_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("reads an existing file", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "Hello, world!");
- const result = await tool.execute({ path: "hello.txt" });
- expect(result).toContain("Hello, world!");
- expect(result).toContain("[file: hello.txt — lines 1-1 of 1]");
- });
-
- it("returns error for non-existent file", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "missing.txt" });
- expect(result).toMatch(/not found/i);
- });
-
- it("blocks path traversal", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "../etc/passwd" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- it("respects offset and limit", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "multi.txt"), "line1\nline2\nline3\nline4\nline5");
- const result = await tool.execute({ path: "multi.txt", offset: 2, limit: 2 });
- expect(result).toContain("line2");
- expect(result).toContain("line3");
- expect(result).not.toContain("line1");
- expect(result).not.toContain("line4");
- expect(result).toContain("[file: multi.txt — lines 2-3 of 5]");
- });
-
- it("truncates long lines and points to read_file_slice", async () => {
- const tool = createReadFileTool(workDir);
- const longLine = "x".repeat(3000);
- await writeFile(join(workDir, "wide.txt"), longLine);
- const result = await tool.execute({ path: "wide.txt" });
- expect(result).toContain("[line 1 truncated, total 3,000 chars");
- expect(result).toContain("use read_file_slice");
- });
-
- // The universal truncator writes oversized tool output to
- // `${SPILL_ROOT}/<tabId>/<callId>.txt` and the truncation notice tells
- // the AI to read that absolute path back. A previous implementation
- // used `resolve(join(workingDirectory, filePath))` which silently
- // concatenated the absolute spill path *under* the workdir, producing
- // a non-existent path and ENOENT — breaking the entire spill-and-resume
- // flow. These tests guard that contract.
- describe("absolute path handling (spill-file regression)", () => {
- let spillSubdir: string;
-
- beforeEach(async () => {
- spillSubdir = join(SPILL_ROOT, `test-${Date.now()}-${randomBytes(4).toString("hex")}`);
- await mkdir(spillSubdir, { recursive: true });
- });
-
- afterEach(async () => {
- await rm(spillSubdir, { recursive: true, force: true });
- });
-
- it("reads a spill file via its absolute path", async () => {
- const tool = createReadFileTool(workDir);
- const spillFile = join(spillSubdir, "call-abc.txt");
- const payload = "spilled output line 1\nspilled output line 2";
- await writeFile(spillFile, payload);
-
- const result = await tool.execute({ path: spillFile });
-
- expect(result).toContain("spilled output line 1");
- expect(result).toContain("spilled output line 2");
- expect(result).not.toMatch(/not found/i);
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("still rejects absolute paths that are neither in the workdir nor the spill root", async () => {
- const tool = createReadFileTool(workDir);
- // Path check happens before file read, so /etc/hostname existing
- // (or not) is irrelevant — we just need an absolute path outside
- // both the workdir and SPILL_ROOT.
- const result = await tool.execute({ path: "/etc/hostname" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlinks must resolve consistently across the agent permission gate
- // and the tool itself. The containment check operates on the canonical
- // path — so a symlink-in-workdir that points outside is treated as
- // "outside" and gated like any other external path. Lexical-only
- // checks would let these slip through silently.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("follows symlinks that stay inside the workdir", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "real.txt"), "real content");
- await symlink(join(workDir, "real.txt"), join(workDir, "link.txt"));
- const result = await tool.execute({ path: "link.txt" });
- expect(result).toContain("real content");
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("blocks symlinks that escape the workdir", async () => {
- const tool = createReadFileTool(workDir);
- const secret = join(externalDir, "secret.txt");
- await writeFile(secret, "leaked secret");
- // Create a symlink *inside* workDir pointing to a file *outside*
- // workDir. Lexical-only path validation would see "workdir/trap.txt"
- // (under workdir) and allow it. Canonical resolution sees the
- // symlink's target and correctly rejects.
- await symlink(secret, join(workDir, "trap.txt"));
- const result = await tool.execute({ path: "trap.txt" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("leaked secret");
- });
- });
-});
diff --git a/packages/core/tests/tools/read-tab.test.ts b/packages/core/tests/tools/read-tab.test.ts
deleted file mode 100644
index 71e419c..0000000
--- a/packages/core/tests/tools/read-tab.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { createReadTabTool, type ReadTabCallbacks } from "../../src/tools/read-tab.js";
-import type { TabResolution } from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<ReadTabCallbacks> = {}): ReadTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- getLastResponse: () => ({ text: "the answer is 42", status: "idle" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- ...overrides,
- };
-}
-
-describe("createReadTabTool — schema & description", () => {
- it("is a non-blocking snapshot read", () => {
- const tool = createReadTabTool(makeCallbacks());
- expect(tool.name).toBe("read_tab");
- expect(tool.description).toContain("SNAPSHOT");
- expect(tool.description.toLowerCase()).toContain("does not block");
- });
-});
-
-describe("createReadTabTool — execute()", () => {
- it("returns the last assistant response wrapped in a tab_response tag", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("<tab_response");
- expect(out).toContain('tab="targ"');
- expect(out).toContain('status="idle"');
- expect(out).toContain("the answer is 42");
- expect(out).toContain("</tab_response>");
- });
-
- it("notes that a running tab's response is its previous completed turn", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: "older turn", status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("still running");
- expect(out).toContain("older turn");
- });
-
- it("explains when a tab has no completed response yet (idle)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "idle" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("no assistant responses yet");
- });
-
- it("explains when a tab is still on its first turn (running, no prior text)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("still working on its first turn");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("returns a helpful error when the id is unknown", async () => {
- const tool = createReadTabTool(makeCallbacks({ resolveShortId: () => ({ status: "none" }) }));
- const out = await tool.execute({ tab_id: "zzzz" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- }),
- );
- const out = await tool.execute({ tab_id: "abcd" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- });
-});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
deleted file mode 100644
index cad75d2..0000000
--- a/packages/core/tests/tools/registry.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { z } from "zod";
-import { createToolRegistry } from "../../src/tools/registry.js";
-import type { ToolDefinition } from "../../src/types/index.js";
-
-const mockTool: ToolDefinition = {
- name: "mock_tool",
- description: "A mock tool for testing",
- parameters: z.object({ input: z.string() }),
- execute: async (_args) => "mock result",
-};
-
-const anotherTool: ToolDefinition = {
- name: "another_tool",
- description: "Another mock tool",
- parameters: z.object({ value: z.number() }),
- execute: async (_args) => "another result",
-};
-
-/** A non-trivial tool that exercises nested objects, required fields, and enums. */
-const complexTool: ToolDefinition = {
- name: "complex_tool",
- description: "A tool with nested parameters",
- parameters: z.object({
- command: z.string().describe("Shell command to run"),
- options: z.object({
- timeout: z.number().optional().describe("Timeout in milliseconds"),
- shell: z.enum(["bash", "sh", "zsh"]).describe("Shell to use"),
- }),
- flags: z.array(z.string()).optional().describe("Additional flags"),
- }),
- execute: async (_args) => "complex result",
-};
-
-describe("createToolRegistry", () => {
- it("returns all tools via getTools()", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tools = registry.getTools();
- expect(tools).toHaveLength(2);
- expect(tools.map((t) => t.name)).toContain("mock_tool");
- expect(tools.map((t) => t.name)).toContain("another_tool");
- });
-
- it("retrieves specific tool by name", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tool = registry.getTool("mock_tool");
- expect(tool).toBeDefined();
- expect(tool?.name).toBe("mock_tool");
- });
-
- it("returns undefined for unknown tool", () => {
- const registry = createToolRegistry([mockTool]);
- expect(registry.getTool("nonexistent")).toBeUndefined();
- });
-
- describe("getAISDKTools", () => {
- it("returns correct keys for all tools", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools).toHaveProperty("mock_tool");
- expect(aiTools).toHaveProperty("another_tool");
- });
-
- it("AI SDK tools have description from ToolDefinition", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools.mock_tool.description).toBe("A mock tool for testing");
- });
-
- it("AI SDK tools surface schema via inputSchema, not parameters", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- // v6 uses inputSchema; v4 used parameters — this verifies the migration
- expect(aiTools.mock_tool).toHaveProperty("inputSchema");
- expect(aiTools.mock_tool).not.toHaveProperty("parameters");
- });
-
- it("AI SDK tools have no execute callback so the SDK does not auto-run", () => {
- const registry = createToolRegistry([mockTool, anotherTool, complexTool]);
- const aiTools = registry.getAISDKTools();
- for (const [name, sdkTool] of Object.entries(aiTools)) {
- expect(
- (sdkTool as Record<string, unknown>).execute,
- `Tool "${name}" should not have an execute callback`,
- ).toBeUndefined();
- }
- });
-
- it("inputSchema produces valid JSONSchema7 for a simple tool", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.mock_tool.inputSchema;
- // jsonSchema() wraps the raw JSONSchema7; it should expose the schema
- // as a `jsonSchema` property on the Schema object
- expect(schema).toBeDefined();
- // The wrapped schema object should carry the JSON Schema definition
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema).toBeDefined();
- expect(schemaObj.jsonSchema.type).toBe("object");
- const props = schemaObj.jsonSchema.properties as Record<string, unknown>;
- expect(props).toHaveProperty("input");
- });
-
- it("inputSchema produces correct JSONSchema7 for a non-trivial nested tool", () => {
- const registry = createToolRegistry([complexTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.complex_tool.inputSchema;
- expect(schema).toBeDefined();
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema.type).toBe("object");
-
- const props = schemaObj.jsonSchema.properties as Record<string, Record<string, unknown>>;
-
- // Top-level required field "command"
- expect(props).toHaveProperty("command");
- expect(props.command.type).toBe("string");
-
- // Nested object "options"
- expect(props).toHaveProperty("options");
- expect(props.options.type).toBe("object");
- const optProps = props.options.properties as Record<string, Record<string, unknown>>;
- expect(optProps).toHaveProperty("shell");
- expect(optProps.shell.enum).toEqual(["bash", "sh", "zsh"]);
-
- // Optional array "flags" present as a property
- expect(props).toHaveProperty("flags");
- expect(props.flags.type).toBe("array");
-
- // Required fields should include "command" and "options"
- const required = schemaObj.jsonSchema.required as string[];
- expect(required).toContain("command");
- expect(required).toContain("options");
- });
-
- it("getTool still returns the original ToolDefinition with execute", () => {
- const registry = createToolRegistry([mockTool]);
- const def = registry.getTool("mock_tool");
- expect(def).toBeDefined();
- expect(typeof def?.execute).toBe("function");
- expect(def?.name).toBe("mock_tool");
- });
- });
-});
diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts
deleted file mode 100644
index cb66d1c..0000000
--- a/packages/core/tests/tools/run-shell.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { createRunShellTool } from "../../src/tools/run-shell.js";
-
-describe("run_shell tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("executes a simple echo command", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo hello" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("hello");
- expect(result.exitCode).toBe(0);
- });
-
- it("returns non-zero exit code on failure", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "exit 42" });
- const result = JSON.parse(raw);
- expect(result.exitCode).toBe(42);
- });
-
- it("captures stderr", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo errormsg >&2" });
- const result = JSON.parse(raw);
- expect(result.stderr.trim()).toBe("errormsg");
- });
-
- it("handles timeout", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "sleep 10", timeout: 100 });
- const result = JSON.parse(raw);
- // Either times out (non-zero exit) or returns an error
- expect(result.exitCode !== 0 || result.error !== undefined).toBe(true);
- }, 5000);
-
- it("executes in the working directory", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "pwd" });
- const result = JSON.parse(raw);
- // On macOS /tmp is symlinked; use includes check
- expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, ""));
- });
-
- it("calls onOutput callback with stdout chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- const raw = await tool.execute({ command: "echo streaming" }, { onOutput });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("streaming");
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout");
- });
-
- it("calls onOutput callback with stderr chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- await tool.execute({ command: "echo errdata >&2" }, { onOutput });
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr");
- });
-
- it("works without context (backward compatible)", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo nocontext" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("nocontext");
- });
-});
diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts
deleted file mode 100644
index c4e933c..0000000
--- a/packages/core/tests/tools/search-code.test.ts
+++ /dev/null
@@ -1,511 +0,0 @@
-import { spawnSync } from "node:child_process";
-import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { mkdtemp as mkdtempP, rm as rmP, writeFile as writeFileP } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createSearchCodeTool } from "../../src/tools/search-code.js";
-
-// A tiny stub that impersonates `cs`: it ignores its args and prints whatever
-// JSON we put in the CS_STUB_OUTPUT env var. This makes JSON→text formatting
-// tests fully deterministic without needing a real cs binary in CI.
-function writeStub(dir: string, body: string): string {
- const stubPath = join(dir, "cs-stub.sh");
- writeFileSync(stubPath, body, { mode: 0o755 });
- chmodSync(stubPath, 0o755);
- return stubPath;
-}
-
-const ECHO_ENV_STUB = `#!/usr/bin/env bash
-printf '%s' "$CS_STUB_OUTPUT"
-`;
-
-// A stub that writes to stderr and exits non-zero, impersonating a cs failure
-// (bad flag, invalid regex, etc.).
-const FAIL_STUB = `#!/usr/bin/env bash
-echo "cs: simulated failure on stderr" >&2
-exit 3
-`;
-
-describe("search_code tool", () => {
- let workDir: string;
- const savedBin = process.env.DISPATCH_CS_BIN;
- const savedStubOut = process.env.CS_STUB_OUTPUT;
-
- beforeEach(async () => {
- workDir = await mkdtempP(join(tmpdir(), "dispatch-cs-test-"));
- });
-
- afterEach(async () => {
- await rmP(workDir, { recursive: true, force: true });
- if (savedBin === undefined) delete process.env.DISPATCH_CS_BIN;
- else process.env.DISPATCH_CS_BIN = savedBin;
- if (savedStubOut === undefined) delete process.env.CS_STUB_OUTPUT;
- else process.env.CS_STUB_OUTPUT = savedStubOut;
- });
-
- it("exposes the expected name and schema", () => {
- const tool = createSearchCodeTool(workDir);
- expect(tool.name).toBe("search_code");
- expect(tool.description).toContain("cs");
- // query is required; a representative set of optional knobs exist.
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect(shape.query).toBeDefined();
- expect(shape.path).toBeDefined();
- expect(shape.only).toBeDefined();
- expect(shape.result_limit).toBeDefined();
- });
-
- it("requires a non-empty query", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: " " });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("query is required");
- });
-
- it("does not crash when params are the wrong type (model hallucination)", async () => {
- const tool = createSearchCodeTool(workDir);
- // A non-string query must be rejected gracefully, not throw.
- const q = await tool.execute({ query: ["a", "b"] as unknown as string });
- expect(q).toMatch(/^Error:/);
- expect(q).toContain("query is required");
- // A non-string include_ext (array) must not throw "x.trim is not a function".
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const out = await tool.execute({
- query: "x",
- include_ext: ["ts", "go"] as unknown as string,
- exclude_pattern: { a: 1 } as unknown as string,
- });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("rejects a path outside the working directory", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything", path: "../../etc" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("outside the working directory");
- });
-
- it("rejects a path that points at a file, not a directory", async () => {
- await writeFileP(join(workDir, "a-file.ts"), "const x = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "a-file.ts" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("is a file, not a directory");
- });
-
- it("rejects a path that does not exist", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "no/such/dir" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("does not exist");
- });
-
- it("returns an actionable error when the cs binary is missing", async () => {
- process.env.DISPATCH_CS_BIN = "/nonexistent/path/to/cs-binary-xyz";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("requires the 'cs'");
- expect(out).toContain("DISPATCH_CS_BIN");
- });
-
- it("reports no matches when cs outputs null", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "nothinghere" });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("formats cs JSON results into readable per-file blocks", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const csJson = JSON.stringify([
- {
- filename: "web-search.ts",
- location: join(workDir, "packages/core/src/tools/web-search.ts"),
- score: 5.24,
- language: "TypeScript",
- total_lines: 106,
- lines: [
- { line_number: 7, content: "" },
- {
- line_number: 8,
- content: "export function createWebSearchTool(): ToolDefinition {",
- match_positions: [[16, 35]],
- },
- { line_number: 9, content: "\treturn {" },
- ],
- },
- {
- filename: "index.ts",
- location: join(workDir, "packages/core/src/index.ts"),
- score: 1.1,
- language: "TypeScript",
- lines: [{ line_number: 113, content: 'export { createWebSearchTool } from "./web.js";' }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
-
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "createWebSearchTool" });
-
- expect(out).toContain("Found matches in 2 files");
- // Paths are rendered relative to the workdir.
- expect(out).toContain("packages/core/src/tools/web-search.ts [TypeScript] (score 5.24)");
- expect(out).not.toContain(workDir);
- // Matched line is marked with '>'; line numbers + content present.
- expect(out).toContain("> 8: export function createWebSearchTool(): ToolDefinition {");
- expect(out).toContain(" 7: ");
- expect(out).toContain("packages/core/src/index.ts [TypeScript] (score 1.10)");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("renders cs 'content'-shape (prose) results instead of a bare header", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- // cs's snippet mode emits `content` + `matchlocations` and no `lines`.
- const csJson = JSON.stringify([
- {
- filename: "notes.md",
- location: join(workDir, "docs/notes.md"),
- score: 0.42,
- language: "Markdown",
- content: "Some heading\nthe orchestration paragraph that matched",
- matchlocations: [[13, 26]],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "orchestration" });
- expect(out).toContain("docs/notes.md [Markdown] (score 0.42)");
- // The snippet text must be present, not a bare header.
- expect(out).toContain("the orchestration paragraph that matched");
- expect(out).not.toContain("no snippet available");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("truncates an excessively long snippet line", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const longContent = `const x = "${"Z".repeat(5000)}";`;
- const csJson = JSON.stringify([
- {
- filename: "big.ts",
- location: join(workDir, "big.ts"),
- score: 1,
- language: "TypeScript",
- lines: [{ line_number: 1, content: longContent, match_positions: [[10, 14]] }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toContain("line truncated");
- // No single output line should approach the raw 5k length.
- const longest = Math.max(...out.split("\n").map((l) => l.length));
- expect(longest).toBeLessThan(700);
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("surfaces raw output when cs returns unparseable JSON", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "this is not json";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("could not parse cs output");
- expect(out).toContain("this is not json");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("reports an error (not 'No matches') when cs exits non-zero", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, FAIL_STUB);
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("exited with code 3");
- // stderr from cs is surfaced to the caller.
- expect(out).toContain("simulated failure on stderr");
- expect(out).not.toContain("No matches found");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- // ── Live integration: only runs when a real `cs` binary is available. ──
- const liveCsBin = findRealCs();
- describe.runIf(liveCsBin)("live cs binary", () => {
- it("finds a real match and ranks the defining file", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // Seed a small tree with a clear match.
- await writeFileP(
- join(workDir, "alpha.ts"),
- "export function findTheNeedle() {\n return 42;\n}\n",
- );
- await writeFileP(join(workDir, "beta.ts"), "const x = 1;\n// nothing relevant here\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "findTheNeedle" });
- expect(out).toContain("alpha.ts");
- expect(out).toContain("findTheNeedle");
- expect(out).not.toContain("Error:");
- });
-
- it("treats a dash-leading query as a search term, not a cs flag", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // A literal token beginning with '-' must not be parsed as a flag.
- await writeFileP(join(workDir, "dash.ts"), "const dashToken = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "-dashToken" });
- // Whether or not cs ranks a hit, it must NOT error out on flag parsing.
- expect(out).not.toContain("unknown shorthand flag");
- expect(out).not.toMatch(/^Error: cs exited/);
- });
-
- it("renders snippet lines for prose (markdown) matches", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(
- join(workDir, "doc.md"),
- "# Title\n\nThis paragraph mentions widgetronics in prose.\n",
- );
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "widgetronics" });
- expect(out).toContain("doc.md");
- // The matching prose text must be shown, not just a bare header.
- expect(out).toContain("widgetronics");
- expect(out).not.toContain("no snippet available");
- });
-
- it("widens the snippet window when context is given", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- const body = Array.from({ length: 21 }, (_, i) => `line ${i + 1}`);
- body[10] = "const findContextTarget = 1;";
- await writeFileP(join(workDir, "ctx.ts"), `${body.join("\n")}\n`);
- const tool = createSearchCodeTool(workDir);
- const countSnippetLines = (s: string) =>
- s.split("\n").filter((l) => /^\s+>?\s*\d+:/.test(l)).length;
- const narrow = await tool.execute({
- query: "findContextTarget",
- context: 0,
- result_limit: 1,
- });
- const wide = await tool.execute({
- query: "findContextTarget",
- context: 6,
- result_limit: 1,
- });
- expect(countSnippetLines(wide)).toBeGreaterThan(countSnippetLines(narrow));
- });
-
- it("returns 'No matches found.' for a query with no hits", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "alpha.ts"), "export const a = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "zzz_nonexistent_token_qqq" });
- expect(out).toBe("No matches found.");
- });
-
- it("tags .luau files as Luau", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "mod.luau"), "function Mod.doThing()\n\treturn 1\nend\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "doThing" });
- expect(out).toContain("mod.luau");
- expect(out).toContain("[Luau]");
- });
- });
-
- // ── Luau declaration detection: needs a cs built with the Luau patch
- // (docker/cs/luau-declarations.patch). Skipped on an unpatched/older cs. ──
- const luauCsBin = findLuauCapableCs(liveCsBin);
- describe.runIf(luauCsBin)("live cs binary (Luau declaration patch)", () => {
- // A small Luau module exercising every declaration form the patch adds.
- const LUAU_MODULE = [
- "local Mod = {}",
- "",
- "export type StuntResult = {",
- "\tscore: number,",
- "}",
- "",
- "type LaunchConfig = StuntResult",
- "",
- "function Mod.getDefaults(): LaunchConfig",
- "\treturn { score = 0 }",
- "end",
- "",
- "local function helperThing(x: number): number",
- "\treturn x + 1",
- "end",
- "",
- "Mod.live = Mod.getDefaults()",
- "local used = helperThing(1)",
- "",
- ].join("\n");
-
- beforeEach(async () => {
- process.env.DISPATCH_CS_BIN = luauCsBin as string;
- await writeFileP(join(workDir, "Mod.luau"), LUAU_MODULE);
- });
-
- it("detects `function Mod.x` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("function Mod.getDefaults");
- expect(out).not.toContain("No matches found");
- });
-
- it("detects `local function` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "helperThing", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("local function helperThing");
- });
-
- it("detects `type` / `export type` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const exportType = await tool.execute({ query: "StuntResult", only: "declarations" });
- expect(exportType).toContain("export type StuntResult");
- const aliasType = await tool.execute({ query: "LaunchConfig", only: "declarations" });
- expect(aliasType).toContain("type LaunchConfig");
- });
-
- it("excludes declaration lines when only=usages", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "usages" });
- // The call site is a usage; the `function Mod.getDefaults` definition is not.
- expect(out).toContain("Mod.live = Mod.getDefaults()");
- expect(out).not.toContain("function Mod.getDefaults");
- });
- });
-
- // ── Fuzzy mid-word matching: needs a cs built with the fuzzy patch
- // (docker/cs/fuzzy-distance.patch). Skipped on an unpatched/older cs. ──
- const fuzzyCsBin = findFuzzyCapableCs(liveCsBin);
- describe.runIf(fuzzyCsBin)("live cs binary (fuzzy edit-distance patch)", () => {
- beforeEach(() => {
- process.env.DISPATCH_CS_BIN = fuzzyCsBin as string;
- });
-
- it("matches a mid-word deletion within distance 1", async () => {
- await writeFileP(
- join(workDir, "phys.ts"),
- "export function computeSlipAngle() {\n\treturn 0;\n}\n",
- );
- const tool = createSearchCodeTool(workDir);
- // "computSlipAngle" drops the 'e' mid-word — edit distance 1.
- const out = await tool.execute({ query: "computSlipAngle~1" });
- expect(out).toContain("phys.ts");
- expect(out).toContain("computeSlipAngle");
- expect(out).not.toBe("No matches found.");
- });
-
- it("matches a mid-word insertion within distance 1", async () => {
- await writeFileP(join(workDir, "tire.ts"), "const tireFriction = 1;\n");
- const tool = createSearchCodeTool(workDir);
- // "tireFricction" has an extra 'c' — edit distance 1.
- const out = await tool.execute({ query: "tireFricction~1" });
- expect(out).toContain("tire.ts");
- expect(out).toContain("tireFriction");
- });
- });
-});
-
-/**
- * Locate a usable `cs` binary for live tests. Honors DISPATCH_CS_TEST_BIN, then
- * a `cs` on PATH. Returns null when none is runnable, so the live suite is
- * skipped rather than failing in environments without cs.
- */
-function findRealCs(): string | null {
- const candidates = [process.env.DISPATCH_CS_TEST_BIN, "cs"].filter(Boolean) as string[];
- for (const bin of candidates) {
- try {
- const res = spawnSync(bin, ["--version"], { stdio: "ignore" });
- if (res.status === 0) return bin;
- } catch {
- // try next
- }
- }
- return null;
-}
-
-/**
- * Probe a `cs` binary against a throwaway corpus and return its trimmed stdout
- * (or "" on any failure). Used by the capability gates below so patch-dependent
- * live tests run only on a cs that actually has the patch — and skip (not fail)
- * on an unpatched/older binary.
- */
-function probeCs(bin: string, files: Record<string, string>, args: string[]): string {
- let dir: string | undefined;
- try {
- dir = mkdtempSync(join(tmpdir(), "dispatch-cs-probe-"));
- for (const [name, body] of Object.entries(files)) {
- writeFileSync(join(dir, name), body);
- }
- const res = spawnSync(bin, ["-f", "json", "--dir", dir, ...args], {
- encoding: "utf8",
- });
- if (res.status !== 0 || !res.stdout) return "";
- return res.stdout.trim();
- } catch {
- return "";
- } finally {
- if (dir) rmSync(dir, { recursive: true, force: true });
- }
-}
-
-/**
- * Return the cs binary only if it recognises Luau declarations (i.e. was built
- * with docker/cs/luau-declarations.patch): a `--only-declarations` search for a
- * top-level `function` in a .luau file yields a result. Otherwise null → skip.
- */
-function findLuauCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.luau": "function Probe.thing()\n\treturn 1\nend\n" }, [
- "--only-declarations",
- "--",
- "thing",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
-
-/**
- * Return the cs binary only if its fuzzy matcher honours mid-word edits (i.e.
- * was built with docker/cs/fuzzy-distance.patch): a distance-1 deletion matches.
- * Otherwise null → skip.
- */
-function findFuzzyCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.txt": "const x = computeSlipAngle;\n" }, [
- "--",
- "computSlipAngle~1",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
diff --git a/packages/core/tests/tools/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts
deleted file mode 100644
index 21d8032..0000000
--- a/packages/core/tests/tools/send-to-tab.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- createSendToTabTool,
- type SendToTabCallbacks,
- type TabResolution,
-} from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- deliver: () => ({ status: "started" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- self: { id: "self-id", handle: "self" },
- canReadTab: true,
- ...overrides,
- };
-}
-
-describe("createSendToTabTool — schema & description", () => {
- it("exposes tab_id and message params and a fire-and-forget description", () => {
- const tool = createSendToTabTool(makeCallbacks());
- expect(tool.name).toBe("send_to_tab");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description.toLowerCase()).toContain("queued");
- // Description must steer the model away from busy-waiting for a reply.
- expect(tool.description.toLowerCase()).toContain("do not sleep");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-
- it("mentions read_tab in the description only when canReadTab is true", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: true }));
- expect(tool.description).toContain("read_tab");
- });
-
- it("never mentions read_tab in the description when canReadTab is false", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: false }));
- expect(tool.description).not.toContain("read_tab");
- // Still tells the agent a reply will wake it + to end its turn.
- expect(tool.description.toLowerCase()).toContain("wake you with a new message");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-});
-
-describe("createSendToTabTool — execute()", () => {
- it("delivers to a resolved target and reports the started status", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "hello there" });
- expect(deliver).toHaveBeenCalledTimes(1);
- const [targetId, delivered] = deliver.mock.calls[0] ?? [];
- expect(targetId).toBe("target-id");
- // Provenance header names the sending tab's handle and marks it as a
- // peer agent (not the recipient's own user).
- expect(delivered).toContain("[message from tab self");
- expect(delivered).toContain("another agent");
- expect(delivered).toContain("hello there");
- // Reply contract: the recipient must answer via send_to_tab back to the
- // sender's handle, not as a plain text reply to its own user.
- expect(delivered).toContain('send_to_tab tool with tab_id "self"');
- expect(delivered).toContain("ONLY reply if");
- expect(out).toContain("idle");
- expect(out).toContain("targ");
- // Sender is steered away from busy-waiting and told to end its turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("points the sender at read_tab in the result only when canReadTab is true", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: true }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("read_tab");
- });
-
- it("omits read_tab from the result when canReadTab is false", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: false }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).not.toContain("read_tab");
- // Still steers away from busy-waiting and toward ending the turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("reports the queued status when the target is busy", async () => {
- const deliver = vi.fn(() => ({ status: "queued" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping" });
- expect(out.toLowerCase()).toContain("queued");
- expect(out.toLowerCase()).toContain("busy");
- });
-
- it("reports a HELD message when delivery is suppressed (auto-wake limit hit)", async () => {
- const deliver = vi.fn(() => ({ status: "suppressed" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping again" });
- expect(out).toContain("HELD");
- expect(out.toLowerCase()).toContain("limit");
- // It must steer the sender away from retrying in a loop.
- expect(out.toLowerCase()).toContain("do not keep resending");
- expect(out.toLowerCase()).toContain("human");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createSendToTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: " ", message: "hi" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("rejects an empty message", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: " " });
- expect(out).toContain("Error");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error and open-tab list when the id is unknown", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({ status: "none" }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "zzzz", message: "hi" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "abcd", message: "hi" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("refuses to send to its own tab", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ok",
- tab: { id: "self-id", title: "Me", handle: "self" },
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "self", message: "hi" });
- expect(out).toContain("cannot send a message to your own tab");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("surfaces a thrown delivery error instead of crashing", async () => {
- const tool = createSendToTabTool(
- makeCallbacks({
- deliver: () => {
- throw new Error("boom");
- },
- }),
- );
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("Error delivering message");
- expect(out).toContain("boom");
- });
-});
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
deleted file mode 100644
index 4885a94..0000000
--- a/packages/core/tests/tools/summon.test.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- type AvailableAgent,
- createSummonTool,
- type SummonCallbacks,
-} from "../../src/tools/summon.js";
-
-const noopCallbacks: SummonCallbacks = {
- spawn: async () => "agent-id-stub",
- getResult: async () => ({ status: "done", result: "" }),
-};
-
-describe("createSummonTool — description content", () => {
- it("lists the agent directories so the LLM knows where to look", () => {
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- [],
- [],
- ["/home/u/.config/dispatch/agents", "/tmp/work/.dispatch/agents"],
- );
- expect(tool.description).toContain("/home/u/.config/dispatch/agents");
- expect(tool.description).toContain("/tmp/work/.dispatch/agents");
- expect(tool.description).toContain("read_file");
- });
-
- it("includes available agent slugs+names in the description", () => {
- const agents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Implements code from a plan.",
- path: "/home/u/.config/dispatch/agents/programmer.toml",
- },
- {
- slug: "researcher",
- name: "Researcher",
- description: "Investigates topics.",
- path: "/home/u/.config/dispatch/agents/researcher.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- agents,
- [],
- ["/home/u/.config/dispatch/agents"],
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("Programmer");
- expect(tool.description).toContain("Implements code from a plan");
- expect(tool.description).toContain("researcher");
- expect(tool.description).toContain("Investigates topics");
- });
-
- it("emits a 'no agents defined' notice when the catalog is empty", () => {
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- [],
- [],
- ["/home/u/.config/dispatch/agents"],
- );
- expect(tool.description).toContain("No agent definitions are currently defined");
- });
-
- it("shows two groups when userAgentEnabled is true", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- true,
- );
- expect(tool.description).toContain("Subagents (spawned as child tabs):");
- expect(tool.description).toContain(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("default");
- });
-
- it("hides user agents group when userAgentEnabled is false", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- false,
- );
- expect(tool.description).toContain("Available agents:");
- expect(tool.description).not.toContain("User agents");
- // "default" appears in generic description text, so check for the slug listing format
- expect(tool.description).not.toContain("- default: Default");
- });
-});
-
-describe("createSummonTool — execute() argument forwarding", () => {
- it("forwards agent slug through to callbacks.spawn", async () => {
- const spawn = vi.fn(async () => "tab-xyz");
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult: async () => ({ status: "done", result: "ok" }) },
- [],
- [],
- );
- await tool.execute({
- task: "do thing",
- agent: "programmer",
- background: true,
- });
- expect(spawn).toHaveBeenCalledTimes(1);
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({
- task: "do thing",
- agentSlug: "programmer",
- });
- });
-
- it("returns spawned agent_id when background=true (no blocking on result)", async () => {
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "should-not-see" }));
- const tool = createSummonTool("/tmp/work", { spawn: async () => "id-42", getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "test-agent", background: true });
- expect(out).toContain("id-42");
- // Background mode must not block on getResult
- expect(getResult).not.toHaveBeenCalled();
- });
-
- it("blocks on result and returns it when background=false (default)", async () => {
- const tool = createSummonTool(
- "/tmp/work",
- {
- spawn: async () => "id-1",
- getResult: async () => ({ status: "done", result: "child-output" }),
- },
- [],
- [],
- );
- const out = await tool.execute({ task: "x", agent: "test-agent" });
- // Foreground summons prefix the blocked result with `agent_id: <id>` so
- // the frontend's ToolCallDisplay regex can surface the "Open Tab" button
- // (see summon.ts). Assert both the prefix and the child output survive.
- expect(out).toContain("agent_id: id-1");
- expect(out).toBe("agent_id: id-1\n\nchild-output");
- });
-
- it("surfaces child errors when blocking", async () => {
- const tool = createSummonTool(
- "/tmp/work",
- {
- spawn: async () => "id-1",
- getResult: async () => ({ status: "error", error: "boom" }),
- },
- [],
- [],
- );
- const out = await tool.execute({ task: "x", agent: "test-agent" });
- expect(out).toContain("boom");
- });
-
- it("returns fire-and-forget message when top_level=true", async () => {
- const spawn = vi.fn(async () => "ua-tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- true, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true,
- });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-tab-1");
- expect(out).toContain("fire-and-forget");
- expect(getResult).not.toHaveBeenCalled();
-
- // Verify topLevel was forwarded to spawn
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true });
- });
-
- it("ignores top_level when userAgentEnabled is false", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "result" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- false, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true, // should be ignored
- });
- // Should behave as a normal foreground summon, not fire-and-forget
- expect(out).not.toContain("fire-and-forget");
- expect(getResult).toHaveBeenCalled();
- });
-});
-
-describe("createSummonTool — user-agent-only mode (perm_user_agent without perm_summon)", () => {
- // userAgentEnabled=true, subagentEnabled=false → the tool spawns ONLY
- // top-level user agents. `top_level` is implied (and forced), the
- // subagent/parallel-work prose is dropped, and only the user-agent
- // catalog group is shown.
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
-
- function userAgentOnlyTool(
- spawn = vi.fn(async () => "ua-1"),
- getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" })),
- ) {
- return {
- spawn,
- getResult,
- tool: createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- subagents,
- userAgents,
- ["/agents"],
- true, // userAgentEnabled
- false, // subagentEnabled
- ),
- };
- }
-
- it("describes spawning user agents and omits subagent/parallel-work prose", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("Spawn an independent top-level user agent");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description).not.toContain("Pattern for parallel work");
- expect(tool.description).not.toContain("Set background=true");
- });
-
- it("lists only the user-agent catalog group, not subagents", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("User agents (spawned as independent top-level tabs):");
- expect(tool.description).toContain("default");
- // Subagents must not be advertised in user-agent-only mode.
- expect(tool.description).not.toContain("Subagents (spawned as child tabs):");
- expect(tool.description).not.toContain("- programmer: Programmer");
- });
-
- it("only lists user-agent slugs in the 'agent' parameter description", () => {
- const { tool } = userAgentOnlyTool();
- const agentParam = (tool.parameters as unknown as { shape: { agent: { description: string } } })
- .shape.agent;
- expect(agentParam.description).toContain("default");
- expect(agentParam.description).not.toContain("programmer");
- });
-
- it("omits the top_level parameter (it is implied)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("top_level" in shape).toBe(false);
- });
-
- it("omits the background parameter (user agents are fire-and-forget)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("background" in shape).toBe(false);
- });
-
- it("forces topLevel=true on spawn even when top_level is not passed", async () => {
- const spawn = vi.fn(async () => "ua-99");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const { tool } = userAgentOnlyTool(spawn, getResult);
- const out = await tool.execute({ task: "do stuff", agent: "default" });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-99");
- expect(out).toContain("fire-and-forget");
- // Never blocks on a result for fire-and-forget user agents.
- expect(getResult).not.toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true, agentSlug: "default" });
- });
-});
-
-describe("createSummonTool — subagentEnabled defaults preserve legacy behavior", () => {
- it("defaults subagentEnabled=true so omitting it keeps subagent spawning", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "child" }));
- // No userAgentEnabled/subagentEnabled args → legacy subagent-only mode.
- const tool = createSummonTool("/tmp/work", { spawn, getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "programmer" });
- // Foreground subagent summon blocks and returns the child result.
- expect(out).toBe("agent_id: tab-1\n\nchild");
- expect(getResult).toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).not.toHaveProperty("topLevel");
- });
-});
diff --git a/packages/core/tests/tools/task-list.test.ts b/packages/core/tests/tools/task-list.test.ts
deleted file mode 100644
index 5903fec..0000000
--- a/packages/core/tests/tools/task-list.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createTaskListTool, TaskList } from "../../src/tools/task-list.js";
-import type { TaskItem } from "../../src/types/index.js";
-
-describe("TaskList (declarative store)", () => {
- it("starts empty", () => {
- const list = new TaskList();
- expect(list.getTasks()).toEqual([]);
- });
-
- it("setTasks replaces the whole list and assigns positional ids", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "first", status: "in_progress" },
- { content: "second", status: "pending" },
- ]);
- expect(result).toEqual([
- { id: "task-1", content: "first", status: "in_progress" },
- { id: "task-2", content: "second", status: "pending" },
- ]);
- expect(list.getTasks()).toEqual(result);
- });
-
- it("a second setTasks fully replaces the previous list (no append)", () => {
- const list = new TaskList();
- list.setTasks([
- { content: "a", status: "completed" },
- { content: "b", status: "completed" },
- { content: "c", status: "pending" },
- ]);
- const next = list.setTasks([{ content: "only", status: "in_progress" }]);
- expect(next).toEqual([{ id: "task-1", content: "only", status: "in_progress" }]);
- expect(list.getTasks()).toHaveLength(1);
- });
-
- it("preserves all four statuses", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "p", status: "pending" },
- { content: "i", status: "in_progress" },
- { content: "c", status: "completed" },
- { content: "x", status: "cancelled" },
- ]);
- expect(result.map((t) => t.status)).toEqual([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
- ]);
- });
-
- it("defaults missing/invalid status to pending", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "no status" },
- { content: "bogus", status: "done" },
- { content: "junk", status: 42 },
- ]);
- expect(result.map((t) => t.status)).toEqual(["pending", "pending", "pending"]);
- });
-
- it("an empty array clears the list", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- expect(list.setTasks([])).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("getTasks returns copies (no external mutation leaks in)", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const snapshot = list.getTasks();
- snapshot[0].content = "mutated";
- expect(list.getTasks()[0].content).toBe("x");
- });
-
- it("onChange fires on every setTasks with the new snapshot", () => {
- const list = new TaskList();
- const seen: TaskItem[][] = [];
- const unsubscribe = list.onChange((tasks) => seen.push(tasks));
- list.setTasks([{ content: "a", status: "pending" }]);
- list.setTasks([{ content: "b", status: "completed" }]);
- expect(seen).toHaveLength(2);
- expect(seen[0]).toEqual([{ id: "task-1", content: "a", status: "pending" }]);
- expect(seen[1]).toEqual([{ id: "task-1", content: "b", status: "completed" }]);
- unsubscribe();
- list.setTasks([{ content: "c", status: "pending" }]);
- expect(seen).toHaveLength(2);
- });
-});
-
-describe("createTaskListTool", () => {
- it("exposes a single declarative `todos` parameter and the name `todo`", () => {
- const tool = createTaskListTool(new TaskList());
- expect(tool.name).toBe("todo");
- // One top-level param: the whole-list `todos` array.
- const shape = (tool.parameters as { shape: Record<string, unknown> }).shape;
- expect(Object.keys(shape)).toEqual(["todos"]);
- });
-
- it("execute updates the store and echoes the list WITHOUT ids", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({
- todos: [
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ],
- });
- expect(JSON.parse(out)).toEqual([
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ]);
- // Store has ids; the echo does not.
- expect(list.getTasks()).toEqual([
- { id: "task-1", content: "plan", status: "completed" },
- { id: "task-2", content: "build", status: "in_progress" },
- ]);
- });
-
- it("execute fires onChange so the UI broadcast is wired", async () => {
- const list = new TaskList();
- const cb = vi.fn();
- list.onChange(cb);
- const tool = createTaskListTool(list);
- await tool.execute({ todos: [{ content: "x", status: "pending" }] });
- expect(cb).toHaveBeenCalledTimes(1);
- });
-
- it("execute with an empty array clears the store", async () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [] });
- expect(JSON.parse(out)).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("execute defaults invalid status to pending in both store and echo", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [{ content: "x", status: "done" }] });
- expect(JSON.parse(out)).toEqual([{ content: "x", status: "pending" }]);
- expect(list.getTasks()[0].status).toBe("pending");
- });
-
- it("execute rejects a non-array todos param", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: "nope" });
- expect(out).toMatch(/Error/);
- });
-
- it("execute rejects items missing a content string", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: [{ status: "pending" }] });
- expect(out).toMatch(/Error/);
- });
-});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
deleted file mode 100644
index 0dedbfc..0000000
--- a/packages/core/tests/tools/write-file.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { access, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createWriteFileTool } from "../../src/tools/write-file.js";
-
-describe("write_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("writes a new file", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "output.txt",
- content: "test content",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "output.txt"), "utf8");
- expect(written).toBe("test content");
- });
-
- it("creates parent directories", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "nested/dir/file.txt",
- content: "nested",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
- expect(written).toBe("nested");
- });
-
- it("blocks path traversal", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, filePath))` — when filePath
- // is absolute, `join` does NOT short-circuit, it concatenates. The old code
- // silently rewrote `/etc/foo` to `<workdir>/etc/foo` and "succeeded" by
- // writing to the wrong location. After the fix, absolute paths resolve
- // to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("writes an absolute path that lives under the workdir to the expected location", async () => {
- const tool = createWriteFileTool(workDir);
- const absoluteTarget = join(workDir, "abs.txt");
- const result = await tool.execute({ path: absoluteTarget, content: "abs content" });
- expect(result).toMatch(/successfully wrote/i);
- // File must exist at exactly `absoluteTarget`, NOT at
- // `<workdir>/<workdir>/abs.txt` (the old mangled location).
- const written = await readFile(absoluteTarget, "utf8");
- expect(written).toBe("abs content");
- });
-
- it("rejects absolute paths outside the workdir instead of silently mangling them", async () => {
- const tool = createWriteFileTool(workDir);
- // Pick a path under tmpdir that's definitely not under workDir.
- // Under the bug, this got rewritten to `<workdir>/tmp/...` and the
- // write "succeeded" at the wrong location.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}.txt`);
- const result = await tool.execute({ path: evilPath, content: "should not land" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlink containment: even when the *leaf* doesn't exist yet (the
- // common case for write_file creating a new file), `canonicalize`
- // must walk up to the nearest existing ancestor and resolve symlinks
- // there. Otherwise, a directory symlink inside workdir pointing
- // outside lets a write escape the workspace.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks writes that escape through a parent symlink (leaf does not exist yet)", async () => {
- const tool = createWriteFileTool(workDir);
- // `escape` is a symlink *inside* workdir to a directory *outside*.
- await symlink(externalDir, join(workDir, "escape"));
- const result = await tool.execute({
- path: "escape/payload.txt",
- content: "malicious payload",
- });
- expect(result).toMatch(/outside the working directory/i);
- // And the file must NOT exist in externalDir.
- await expect(access(join(externalDir, "payload.txt"))).rejects.toThrow();
- // And externalDir should be empty (nothing leaked through).
- const entries = await readdir(externalDir);
- expect(entries).toEqual([]);
- });
- });
-
- describe("onAfterWrite hook", () => {
- it("appends the hook's returned string to a successful write", async () => {
- const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`);
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).toContain("DIAGNOSTICS for");
- expect(result).toContain(join(workDir, "a.luau"));
- });
-
- it("does not append when the hook returns empty string", async () => {
- const tool = createWriteFileTool(workDir, async () => "");
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/);
- });
-
- it("does not run the hook when the write is blocked (traversal)", async () => {
- let called = false;
- const tool = createWriteFileTool(workDir, async () => {
- called = true;
- return "should not appear";
- });
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- expect(called).toBe(false);
- });
-
- it("swallows hook errors so a throwing hook never fails the write", async () => {
- const tool = createWriteFileTool(workDir, async () => {
- throw new Error("lsp blew up");
- });
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).not.toContain("lsp blew up");
- });
-
- it("passes the canonical absolute path to the hook", async () => {
- let seen = "";
- const tool = createWriteFileTool(workDir, async (abs) => {
- seen = abs;
- return "";
- });
- await tool.execute({ path: "nested/b.luau", content: "x" });
- expect(seen).toBe(join(workDir, "nested/b.luau"));
- });
- });
-});
diff --git a/packages/core/tests/types/reasoning-effort.test.ts b/packages/core/tests/types/reasoning-effort.test.ts
deleted file mode 100644
index 97cd26c..0000000
--- a/packages/core/tests/types/reasoning-effort.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- REASONING_EFFORT_LABELS,
- REASONING_EFFORTS,
-} from "../../src/types/index.js";
-
-describe("REASONING_EFFORTS — canonical effort list (single source of truth)", () => {
- it("is ordered least→most and includes xhigh between high and max", () => {
- expect(REASONING_EFFORTS).toEqual(["none", "low", "medium", "high", "xhigh", "max"]);
- const hi = REASONING_EFFORTS.indexOf("high");
- const xhi = REASONING_EFFORTS.indexOf("xhigh");
- const mx = REASONING_EFFORTS.indexOf("max");
- expect(hi).toBeLessThan(xhi);
- expect(xhi).toBeLessThan(mx);
- });
-
- it("has a human-readable label for every level (no gaps)", () => {
- for (const effort of REASONING_EFFORTS) {
- expect(REASONING_EFFORT_LABELS[effort]).toBeTruthy();
- }
- expect(Object.keys(REASONING_EFFORT_LABELS).sort()).toEqual([...REASONING_EFFORTS].sort());
- });
-
- it("defaults to high", () => {
- expect(DEFAULT_REASONING_EFFORT).toBe("high");
- expect(REASONING_EFFORTS).toContain(DEFAULT_REASONING_EFFORT);
- });
-});
-
-describe("isReasoningEffort", () => {
- it("accepts every canonical level", () => {
- for (const effort of REASONING_EFFORTS) {
- expect(isReasoningEffort(effort)).toBe(true);
- }
- });
-
- it("rejects unknown strings and non-strings", () => {
- expect(isReasoningEffort("turbo")).toBe(false);
- expect(isReasoningEffort("HIGH")).toBe(false);
- expect(isReasoningEffort("")).toBe(false);
- expect(isReasoningEffort(undefined)).toBe(false);
- expect(isReasoningEffort(null)).toBe(false);
- expect(isReasoningEffort(3)).toBe(false);
- expect(isReasoningEffort({})).toBe(false);
- });
-});
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
deleted file mode 100644
index 2d6fedd..0000000
--- a/packages/core/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "types": ["@types/bun"]
- },
- "include": ["src/**/*.ts"],
- "exclude": ["node_modules", "dist", "tests"]
-}
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
deleted file mode 100644
index ba60b3c..0000000
--- a/packages/core/vitest.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- include: ["tests/**/*.test.ts"],
- server: {
- deps: {
- // Force inline resolution for packages that break under Bun's
- // .bun/ symlink layout in Docker environments
- inline: ["zod"],
- },
- },
- },
-});
diff --git a/packages/frontend/electron/main.cjs b/packages/frontend/electron/main.cjs
deleted file mode 100644
index 251e96c..0000000
--- a/packages/frontend/electron/main.cjs
+++ /dev/null
@@ -1,33 +0,0 @@
-const { app, BrowserWindow } = require("electron");
-const path = require("node:path");
-
-function createWindow() {
- const win = new BrowserWindow({
- width: 1280,
- height: 800,
- title: "Dispatch",
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- preload: path.join(__dirname, "preload.cjs"),
- },
- });
-
- win.loadFile(path.join(__dirname, "../dist/index.html"));
-}
-
-app.whenReady().then(() => {
- createWindow();
-
- app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow();
- }
- });
-});
-
-app.on("window-all-closed", () => {
- if (process.platform !== "darwin") {
- app.quit();
- }
-});
diff --git a/packages/frontend/electron/preload.cjs b/packages/frontend/electron/preload.cjs
deleted file mode 100644
index 14ebb26..0000000
--- a/packages/frontend/electron/preload.cjs
+++ /dev/null
@@ -1,7 +0,0 @@
-const { contextBridge } = require("electron");
-
-contextBridge.exposeInMainWorld("versions", {
- electron: process.versions.electron,
- node: process.versions.node,
- chrome: process.versions.chrome,
-});
diff --git a/packages/frontend/index.html b/packages/frontend/index.html
deleted file mode 100644
index 32b56aa..0000000
--- a/packages/frontend/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<!doctype html>
-<html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Dispatch</title>
- </head>
- <body>
- <div id="app"></div>
- <script type="module" src="/src/main.ts"></script>
- </body>
-</html>
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
deleted file mode 100644
index c4966e5..0000000
--- a/packages/frontend/package.json
+++ /dev/null
@@ -1,54 +0,0 @@
-{
- "name": "@dispatch/frontend",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "electron/main.cjs",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "serve": "bun serve.ts",
- "test": "vitest run",
- "test:watch": "vitest",
- "typecheck": "svelte-check --tsconfig ./tsconfig.json",
- "electron": "electron electron/main.cjs",
- "electron:dev": "vite build && electron electron/main.cjs",
- "dist:win": "vite build && electron-builder --win --dir"
- },
- "build": {
- "appId": "com.dispatch.app",
- "productName": "Dispatch",
- "directories": {
- "output": "release"
- },
- "files": [
- "dist/**/*",
- "electron/**/*.cjs"
- ],
- "win": {
- "target": "dir",
- "icon": "../packaging/dispatch.png"
- }
- },
- "dependencies": {
- "@dispatch/core": "workspace:*",
- "dompurify": "^3.4.5",
- "highlight.js": "^11.11.1",
- "marked": "^18.0.4",
- "marked-highlight": "^2.2.4",
- "phosphor-svelte": "^3.1.0",
- "svelte": "^5.0.0"
- },
- "devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^5.0.0",
- "@tailwindcss/vite": "^4.0.0",
- "@types/dompurify": "^3.2.0",
- "daisyui": "^5.0.0",
- "electron": "^35.0.0",
- "electron-builder": "^26.0.0",
- "svelte-check": "^4.0.0",
- "tailwindcss": "^4.0.0",
- "vite": "^6.0.0"
- }
-}
diff --git a/packages/frontend/serve.ts b/packages/frontend/serve.ts
deleted file mode 100644
index 020b698..0000000
--- a/packages/frontend/serve.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-// Static file server for the built frontend (packages/frontend/dist).
-// Uses Bun's built-in HTTP server — no extra runtime dependencies.
-//
-// Environment variables:
-// PORT — port to listen on (default: 18391)
-// HOST — interface to bind to (default: 0.0.0.0)
-// DIST_DIR — absolute path to the static assets directory
-// (default: <this-file's-dir>/dist)
-//
-// SPA fallback: requests that don't match a file on disk fall back to
-// index.html so client-side routing works.
-
-import { existsSync, statSync } from "node:fs";
-import { join, normalize, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const moduleDir = (() => {
- try {
- // @ts-expect-error — Bun provides import.meta.dir
- return import.meta.dir as string;
- } catch {
- return resolve(fileURLToPath(import.meta.url), "..");
- }
-})();
-
-const port = Number(process.env.PORT) || 18391;
-const host = process.env.HOST || "0.0.0.0";
-const distDir = resolve(process.env.DIST_DIR || join(moduleDir, "dist"));
-
-if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
- console.error(`[dispatch-frontend] dist directory not found: ${distDir}`);
- console.error(
- "[dispatch-frontend] Build the frontend first (bun run --cwd packages/frontend build)",
- );
- process.exit(1);
-}
-
-const indexPath = join(distDir, "index.html");
-
-function safeResolve(urlPath: string): string | null {
- // Normalize and prevent path-traversal outside distDir.
- const decoded = decodeURIComponent(urlPath);
- const candidate = resolve(distDir, `.${normalize(decoded)}`);
- if (!candidate.startsWith(distDir)) return null;
- return candidate;
-}
-
-Bun.serve({
- port,
- hostname: host,
- async fetch(req) {
- const url = new URL(req.url);
- let pathname = url.pathname;
- if (pathname === "/" || pathname === "") pathname = "/index.html";
-
- const resolved = safeResolve(pathname);
- if (resolved && existsSync(resolved) && statSync(resolved).isFile()) {
- return new Response(Bun.file(resolved));
- }
-
- // SPA fallback — serve index.html for unknown routes
- return new Response(Bun.file(indexPath), {
- headers: { "content-type": "text/html; charset=utf-8" },
- });
- },
-});
-
-console.log(`[dispatch-frontend] serving ${distDir} on http://${host}:${port}`);
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
deleted file mode 100644
index f5317ba..0000000
--- a/packages/frontend/src/App.svelte
+++ /dev/null
@@ -1,347 +0,0 @@
-<script lang="ts">
-import { DEFAULT_REASONING_EFFORT } from "@dispatch/core/src/types/index.js";
-import { onMount } from "svelte";
-import AgentBuilder from "./lib/components/AgentBuilder.svelte";
-import ChatInput from "./lib/components/ChatInput.svelte";
-import ChatPanel from "./lib/components/ChatPanel.svelte";
-import Header from "./lib/components/Header.svelte";
-import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte";
-import PermissionPrompt from "./lib/components/PermissionPrompt.svelte";
-import SidebarPanel from "./lib/components/SidebarPanel.svelte";
-import TabBar from "./lib/components/TabBar.svelte";
-import { config } from "./lib/config.js";
-import { router } from "./lib/router.svelte.js";
-import { tabStore } from "./lib/tabs.svelte.js";
-import { applyTheme, loadStoredTheme } from "./lib/theme.js";
-import type { KeyInfo } from "./lib/types.js";
-import { wsClient } from "./lib/ws.svelte.js";
-
-let modelsData = $state<{ keys: KeyInfo[] }>({
- keys: [],
-});
-
-let sidebarOpen = $state(true);
-
-// Add Key modal state (rendered at page level to escape sidebar transform)
-let showAddKeyModal = $state(false);
-let addKeyProvider = $state("anthropic");
-let addKeyId = $state("");
-let addKeyError = $state<string | null>(null);
-let addKeySaving = $state(false);
-
-async function addNewKey(): Promise<void> {
- if (!addKeyId.trim()) return;
- addKeySaving = true;
- addKeyError = null;
- try {
- const res = await fetch(`${config.apiBase}/models/add-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: addKeyId.trim(), provider: addKeyProvider }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- addKeyError = data.error ?? "Failed to add key";
- } else {
- showAddKeyModal = false;
- addKeyId = "";
- addKeyProvider = "anthropic";
- await new Promise((r) => setTimeout(r, 500));
- window.location.reload();
- }
- } catch (e) {
- addKeyError = e instanceof Error ? e.message : "Network error";
- } finally {
- addKeySaving = false;
- }
-}
-
-async function fetchModels() {
- try {
- const res = await fetch(`${config.apiBase}/models`);
- if (!res.ok) return;
- const data = await res.json();
- modelsData = {
- keys: data.keys ?? [],
- };
- } catch {
- // ignore fetch errors
- }
-}
-
-$effect(() => {
- if (tabStore.configReloaded) {
- fetchModels();
- }
-});
-
-// ─── Context-window max lookup ─────────────────────────────────
-// Resolve the active model's MAXIMUM context window from models.dev (via the
-// API), so the Context Window sidebar view can show `current / max`. Cached
-// per provider+model; `null` when unknown (the view then hides the
-// denominator/percentage). Only Claude-backed providers are resolvable.
-let contextLimit = $state<number | null>(null);
-const contextLimitCache = new Map<string, number | null>();
-
-$effect(() => {
- const tab = tabStore.activeTab;
- const keyId = tab?.keyId ?? null;
- const modelId = tab?.modelId ?? null;
- const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null;
-
- if (!provider || !modelId) {
- contextLimit = null;
- return;
- }
-
- const cacheKey = `${provider}/${modelId}`;
- if (contextLimitCache.has(cacheKey)) {
- contextLimit = contextLimitCache.get(cacheKey) ?? null;
- return;
- }
-
- // Clear immediately so a slow/failed fetch can't leave the PREVIOUS
- // model's max on screen (which would render this model's tokens against
- // the wrong denominator). The view degrades to a bare token count until
- // the fetch resolves.
- contextLimit = null;
-
- // Fetch is async; guard against a stale response overwriting a newer
- // selection by re-checking the active tab's key/model on resolve.
- void (async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/models/context-limit?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as { contextLimit?: number | null };
- const limit = data.contextLimit ?? null;
- contextLimitCache.set(cacheKey, limit);
- const current = tabStore.activeTab;
- const currentProvider = current?.keyId
- ? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null)
- : null;
- if (currentProvider === provider && current?.modelId === modelId) {
- contextLimit = limit;
- }
- } catch {
- // Leave contextLimit as-is on network error; view falls back to
- // showing the bare token count.
- }
- })();
-});
-
-// ─── Image / PDF capability lookup ─────────────────────────────
-// Resolve whether the active model accepts image/pdf INPUT from models.dev (via
-// the API), so the chat input can block sending an unsupported attachment
-// (no tokens spent) while staying permissive when the capability is unknown.
-// `null` = unknown (catalog offline / unsupported provider) → optimistic allow.
-let imageSupport = $state<{ image: boolean; pdf: boolean } | null>(null);
-const capabilityCache = new Map<string, { image: boolean; pdf: boolean } | null>();
-
-$effect(() => {
- const tab = tabStore.activeTab;
- const keyId = tab?.keyId ?? null;
- const modelId = tab?.modelId ?? null;
- const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null;
-
- if (!provider || !modelId) {
- imageSupport = null;
- return;
- }
-
- const cacheKey = `${provider}/${modelId}`;
- if (capabilityCache.has(cacheKey)) {
- imageSupport = capabilityCache.get(cacheKey) ?? null;
- return;
- }
-
- // Clear immediately so a slow/failed fetch can't leave the PREVIOUS model's
- // capability on screen (which could wrongly block/allow this model).
- imageSupport = null;
-
- void (async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/models/capabilities?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as {
- capabilities?: { image: boolean; pdf: boolean } | null;
- };
- const caps = data.capabilities ?? null;
- capabilityCache.set(cacheKey, caps);
- const current = tabStore.activeTab;
- const currentProvider = current?.keyId
- ? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null)
- : null;
- if (currentProvider === provider && current?.modelId === modelId) {
- imageSupport = caps;
- }
- } catch {
- // Leave imageSupport as null (unknown → permissive) on network error.
- }
- })();
-});
-
-onMount(() => {
- // Apply persisted theme (or the shared DEFAULT_THEME if nothing is
- // stored) so the first paint matches what the Settings panel will
- // show as the selected option. Without this, daisyUI falls back to
- // the first theme in `app.css` (light) while Settings shows "dark".
- applyTheme(loadStoredTheme());
-
- // Connect WebSocket in parallel with hydration. The `statuses`
- // snapshot delivered on WS open is idempotent against
- // already-hydrated tabs (the handler reconciles per-tab).
- wsClient.connect();
-
- // Initial models fetch (fire-and-forget; UI tolerates models
- // arriving later than tabs).
- fetchModels();
-
- // Restore tabs from the backend. The user's previous session is
- // the source of truth; only fall back to a fresh tab if nothing
- // was restored (first-ever load, or DB was wiped, or HTTP failed).
- void (async () => {
- const restored = await tabStore.hydrateFromBackend();
- if (restored === 0 && tabStore.tabs.length === 0) {
- await tabStore.createNewTab();
- }
- })();
-
- return () => {
- wsClient.disconnect();
- };
-});
-</script>
-
-<div class="flex flex-col h-screen overflow-hidden">
- <Header onToggleSidebar={() => sidebarOpen = !sidebarOpen} />
-
- {#if router.page === "dashboard"}
- <div class="flex flex-1 overflow-hidden relative">
- <!-- Main chat area -->
- <div class="flex flex-col flex-1 min-w-0 overflow-hidden">
- <TabBar />
- <div class="flex-1 overflow-hidden">
- <ChatPanel />
- </div>
- <ChatInput {contextLimit} {imageSupport} />
- </div>
-
- <!-- Right sidebar: overlay on small screens, inline on large -->
- <div
- class="shrink-0 overflow-x-hidden flex flex-col transition-[width] duration-300 ease-out
- sm:relative sm:z-auto
- absolute right-0 top-0 bottom-0 z-30"
- class:w-80={sidebarOpen}
- class:w-0={!sidebarOpen}
- >
- <div
- class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <SidebarPanel
- keys={modelsData.keys}
- tasks={tabStore.activeTab?.tasks ?? []}
- cacheStats={tabStore.activeTab?.cacheStats ?? null}
- cacheTabTitle={tabStore.activeTab?.title ?? null}
- {contextLimit}
- permissionLog={tabStore.permissionLog}
- apiBase={config.apiBase}
- activeTabId={tabStore.activeTabId}
- activeKeyId={tabStore.activeTab?.keyId ?? null}
- activeModelId={tabStore.activeTab?.modelId ?? null}
- reasoningEffort={tabStore.activeTab?.reasoningEffort ?? DEFAULT_REASONING_EFFORT}
- activeAgentSlug={tabStore.activeTab?.agentSlug ?? null}
- activeTabParentId={tabStore.activeTab?.parentTabId ?? null}
- activeAgentModels={tabStore.activeTab?.agentModels ?? null}
- workingDirectory={tabStore.activeTab?.workingDirectory ?? null}
- onKeyChange={(keyId) => tabStore.setKey(keyId)}
- onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)}
- onReasoningChange={(effort) => tabStore.setReasoningEffort(effort)}
- onAgentChange={(agent) => tabStore.setAgent(agent)}
- onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
- onCompact={() => { const id = tabStore.activeTab?.id; if (id) void tabStore.startCompaction(id); }}
- canCompact={(tabStore.activeTab?.agentStatus ?? "idle") === "idle" && (tabStore.activeTab?.chunks.length ?? 0) > 0 && !(tabStore.activeTab?.compactingSource) && !(tabStore.activeTab?.isCompacting)}
- compacting={tabStore.activeTab?.isCompacting ?? false}
- onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }}
- />
- </div>
- </div>
- </div>
- {:else if router.page === "agent-builder"}
- <AgentBuilder keys={modelsData.keys} />
- {/if}
-</div>
-
-<!-- Backdrop for sidebar on small screens -->
-{#if sidebarOpen}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="fixed inset-0 bg-black/30 z-20 sm:hidden"
- role="button"
- tabindex="0"
- onclick={() => sidebarOpen = false}
- onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') sidebarOpen = false; }}
- aria-label="Close sidebar"
- ></div>
-{/if}
-
-<!-- Fixed overlay elements -->
-<PermissionPrompt
- pending={tabStore.pendingPermissions}
- onReply={(id, reply) => tabStore.replyPermission(id, reply)}
-/>
-
-<!-- Hot reload indicator fixed top-right -->
-<div class="fixed top-4 right-4 z-50">
- <HotReloadIndicator active={tabStore.configReloaded} />
-</div>
-
-<!-- Add New Key Modal (page-level to escape sidebar transform) -->
-{#if showAddKeyModal}
- <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
- <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
- <h3 class="text-sm font-semibold">Add New Key</h3>
- <div class="flex flex-col gap-1">
- <label class="text-xs text-base-content/60" for="add-key-provider">Provider</label>
- <select id="add-key-provider" class="select select-bordered select-sm w-full" bind:value={addKeyProvider}>
- <option value="anthropic">Anthropic</option>
- <option value="opencode-go">OpenCode</option>
- <option value="google">Google (Gemini)</option>
- </select>
- </div>
- <div class="flex flex-col gap-1">
- <label class="text-xs text-base-content/60" for="add-key-id">Key ID</label>
- <input
- id="add-key-id"
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="e.g. claude-max, copilot-2"
- bind:value={addKeyId}
- />
- <p class="text-xs text-base-content/40">Unique identifier for this key</p>
- </div>
- {#if addKeyError}
- <p class="text-xs text-error">{addKeyError}</p>
- {/if}
- <div class="flex justify-end gap-2">
- <button type="button" class="btn btn-sm btn-ghost"
- onclick={() => { showAddKeyModal = false; }}>
- Cancel
- </button>
- <button type="button" class="btn btn-sm btn-primary"
- disabled={!addKeyId.trim() || addKeySaving}
- onclick={addNewKey}>
- {#if addKeySaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Add Key
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css
deleted file mode 100644
index 169f7e4..0000000
--- a/packages/frontend/src/app.css
+++ /dev/null
@@ -1,111 +0,0 @@
-@import "tailwindcss";
-@import "highlight.js/styles/atom-one-dark.min.css";
-@plugin "daisyui" {
- themes:
- light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte,
- garden, luxury;
-}
-
-.markdown-body {
- & p {
- margin-block: 0.5em;
- &:first-child {
- margin-block-start: 0;
- }
- &:last-child {
- margin-block-end: 0;
- }
- }
- & h1,
- & h2,
- & h3,
- & h4,
- & h5,
- & h6 {
- font-weight: 600;
- line-height: 1.25;
- margin-block: 0.75em 0.25em;
- &:first-child {
- margin-block-start: 0;
- }
- }
- & h1 {
- font-size: 1.4em;
- }
- & h2 {
- font-size: 1.2em;
- }
- & h3 {
- font-size: 1.1em;
- }
- & ul,
- & ol {
- padding-inline-start: 1.5em;
- margin-block: 0.5em;
- }
- & ul {
- list-style-type: disc;
- }
- & ol {
- list-style-type: decimal;
- }
- & li {
- margin-block: 0.15em;
- }
- & pre {
- overflow-x: auto;
- border-radius: var(--radius-box);
- margin-block: 0.5em;
- background-color: oklch(var(--color-neutral));
- color: oklch(var(--color-neutral-content));
- }
- & pre code {
- display: block;
- padding: 0.75em 1em;
- font-size: 0.8125em;
- line-height: 1.5;
- }
- & :not(pre) > code {
- font-size: 0.875em;
- padding: 0.15em 0.4em;
- border-radius: var(--radius-selector);
- background-color: oklch(var(--color-base-content) / 0.1);
- }
- & blockquote {
- border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2);
- padding-inline-start: 0.75em;
- margin-block: 0.5em;
- opacity: 0.8;
- }
- & a {
- color: oklch(var(--color-primary));
- text-decoration: underline;
- &:hover {
- opacity: 0.8;
- }
- }
- & strong {
- font-weight: 600;
- }
- & table {
- width: 100%;
- border-collapse: collapse;
- margin-block: 0.5em;
- font-size: 0.875em;
- }
- & th,
- & td {
- border: 1px solid oklch(var(--color-base-content) / 0.15);
- padding: 0.4em 0.75em;
- text-align: start;
- }
- & th {
- font-weight: 600;
- background-color: oklch(var(--color-base-200));
- }
- & hr {
- border: none;
- border-top: 1px solid oklch(var(--color-base-content) / 0.2);
- margin-block: 0.75em;
- }
-}
diff --git a/packages/frontend/src/lib/attachment-tokens.ts b/packages/frontend/src/lib/attachment-tokens.ts
deleted file mode 100644
index 79d4cbc..0000000
--- a/packages/frontend/src/lib/attachment-tokens.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-// Inline attachment tokens for the chat input.
-//
-// A pasted image/PDF is represented in the textarea draft as an inline TOKEN
-// (e.g. `【image:a1b2c3】`). The token is ordinary text living inside the draft,
-// so attachments have ORDER relative to typed text and to each other, and the
-// user can reference them positionally ("here is image A: 【image:…】"). The
-// token is also the ONLY handle on an attachment — deleting it (atomic delete,
-// below) detaches the underlying file. There is no separate preview strip.
-//
-// This module is pure (no DOM, no Svelte) so it can be unit-tested directly.
-
-import type { UserContentPart } from "@dispatch/core/src/types/index.js";
-
-export type AttachmentKind = "image" | "pdf";
-
-/** A staged attachment, keyed by its short token id. */
-export interface StagedAttachment {
- id: string;
- kind: AttachmentKind;
- /** IANA media type, e.g. `image/png`, `application/pdf`. */
- mediaType: string;
- /** Base64 payload WITHOUT a `data:` URI prefix. */
- data: string;
- /** Optional original filename (used for PDFs). */
- name?: string;
-}
-
-/**
- * Token grammar: `【<kind>:<id>】` where kind ∈ {image,pdf} and id is 6
- * lowercase alphanumerics. The CJK corner brackets (U+3010/U+3011) are used as
- * delimiters because they're visually distinct and virtually never typed by
- * hand, so a token won't collide with normal prose.
- */
-export const ATTACHMENT_TOKEN_RE = /【(image|pdf):([a-z0-9]{6})】/g;
-
-/** Build the inline token string for a staged attachment id + kind. */
-export function makeAttachmentToken(kind: AttachmentKind, id: string): string {
- return `【${kind}:${id}】`;
-}
-
-/** Generate a short, URL-safe token id (6 lowercase alphanumerics). */
-export function generateTokenId(): string {
- let out = "";
- const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
- // crypto.getRandomValues is available in browsers and modern Node/Bun.
- const cryptoObj = (globalThis as { crypto?: Crypto }).crypto;
- if (cryptoObj?.getRandomValues) {
- const buf = new Uint32Array(6);
- cryptoObj.getRandomValues(buf);
- for (let i = 0; i < 6; i++) out += alphabet[(buf[i] ?? 0) % alphabet.length];
- return out;
- }
- for (let i = 0; i < 6; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
- return out;
-}
-
-export interface FoundToken {
- id: string;
- kind: AttachmentKind;
- /** Inclusive start index of the token within the text. */
- start: number;
- /** Exclusive end index of the token within the text. */
- end: number;
-}
-
-/** Find all attachment tokens in `text`, in order of appearance. */
-export function findTokens(text: string): FoundToken[] {
- const out: FoundToken[] = [];
- // Fresh regex per call so `lastIndex` state never leaks between calls.
- const re = new RegExp(ATTACHMENT_TOKEN_RE.source, "g");
- let m: RegExpExecArray | null = re.exec(text);
- while (m !== null) {
- out.push({
- kind: m[1] as AttachmentKind,
- id: m[2] ?? "",
- start: m.index,
- end: m.index + m[0].length,
- });
- m = re.exec(text);
- }
- return out;
-}
-
-/** The set of attachment ids whose token is still intact in `text`. */
-export function intactTokenIds(text: string): Set<string> {
- return new Set(findTokens(text).map((t) => t.id));
-}
-
-export interface DeletionResult {
- /** Text after the deletion. */
- text: string;
- /** New caret position (collapsed) after the deletion. */
- caret: number;
- /** Ids of attachments whose tokens were removed by this deletion. */
- removedIds: string[];
-}
-
-/**
- * Compute the result of a Backspace/Delete keystroke when it interacts with an
- * attachment token, so a token deletes ATOMICALLY (one keystroke removes the
- * whole `【…】`, never a single bracket). Returns `null` when the keystroke does
- * NOT touch a token — the caller should then let the browser's default editing
- * behaviour run.
- *
- * Rules:
- * - Range selection (`selStart !== selEnd`): expand the range to fully cover
- * any token it overlaps, then delete the expanded range. Only acts when at
- * least one token actually overlaps (otherwise returns null).
- * - Collapsed + Backspace: if a token ends exactly at the caret, delete it.
- * - Collapsed + Delete: if a token starts exactly at the caret, delete it.
- */
-export function computeTokenDeletion(
- text: string,
- selStart: number,
- selEnd: number,
- key: "Backspace" | "Delete",
-): DeletionResult | null {
- const tokens = findTokens(text);
- if (tokens.length === 0) return null;
-
- if (selStart !== selEnd) {
- const lo = Math.min(selStart, selEnd);
- const hi = Math.max(selStart, selEnd);
- const overlapping = tokens.filter((t) => t.start < hi && t.end > lo);
- if (overlapping.length === 0) return null;
- const delStart = Math.min(lo, ...overlapping.map((t) => t.start));
- const delEnd = Math.max(hi, ...overlapping.map((t) => t.end));
- return {
- text: text.slice(0, delStart) + text.slice(delEnd),
- caret: delStart,
- removedIds: overlapping.map((t) => t.id),
- };
- }
-
- // Collapsed caret.
- if (key === "Backspace") {
- const tok = tokens.find((t) => t.end === selStart);
- if (!tok) return null;
- return {
- text: text.slice(0, tok.start) + text.slice(tok.end),
- caret: tok.start,
- removedIds: [tok.id],
- };
- }
- // Delete (forward).
- const tok = tokens.find((t) => t.start === selStart);
- if (!tok) return null;
- return {
- text: text.slice(0, tok.start) + text.slice(tok.end),
- caret: tok.start,
- removedIds: [tok.id],
- };
-}
-
-/** Human-readable marker that replaces a token in persisted/display text. */
-export function markerFor(kind: AttachmentKind): string {
- return kind === "pdf" ? "[pdf]" : "[image]";
-}
-
-export interface ParsedDraft {
- /**
- * Text-only projection of the draft with each attachment token replaced by a
- * `[image]` / `[pdf]` marker. This is what gets persisted and rendered in the
- * chat history (the raw bytes are never stored).
- */
- displayText: string;
- /**
- * Ordered multimodal content (interleaved text + attachment parts) to send to
- * the model, or `null` when the draft has no intact attachment token (the
- * caller then sends plain text).
- */
- content: UserContentPart[] | null;
-}
-
-/**
- * Split a draft (text containing attachment tokens) plus the staged-attachment
- * map into:
- * - `displayText`: tokens swapped for `[image]`/`[pdf]` markers, and
- * - `content`: an ordered `UserContentPart[]` interleaving the surrounding text
- * with the matching attachment parts.
- *
- * A token whose id has no matching staged attachment (e.g. a stray paste of the
- * token text, or a detached attachment) is treated as plain text in BOTH
- * outputs — its marker still appears in `displayText`, but it contributes no
- * attachment part. `content` is `null` when no attachment part is produced.
- */
-export function parseDraft(draft: string, attachments: Map<string, StagedAttachment>): ParsedDraft {
- const tokens = findTokens(draft);
- let displayText = "";
- const content: UserContentPart[] = [];
- let textBuf = "";
- let cursor = 0;
- let producedAttachment = false;
-
- const flushText = () => {
- if (textBuf.length > 0) {
- content.push({ type: "text", text: textBuf });
- textBuf = "";
- }
- };
-
- for (const tok of tokens) {
- const between = draft.slice(cursor, tok.start);
- textBuf += between;
- displayText += between;
- const att = attachments.get(tok.id);
- if (att) {
- // displayText (persisted/rendered) gets a `[image]`/`[pdf]` marker;
- // the multimodal content gets the ACTUAL attachment part instead — no
- // marker text, since the part itself represents the file to the model.
- displayText += markerFor(tok.kind);
- flushText();
- content.push({
- type: "attachment",
- mediaType: att.mediaType,
- data: att.data,
- ...(att.name ? { name: att.name } : {}),
- });
- producedAttachment = true;
- } else {
- // Orphan token (no staged attachment) → keep the marker as plain text
- // in BOTH outputs; it contributes no attachment part.
- displayText += markerFor(tok.kind);
- textBuf += markerFor(tok.kind);
- }
- cursor = tok.end;
- }
- const tail = draft.slice(cursor);
- textBuf += tail;
- displayText += tail;
- flushText();
-
- return { displayText, content: producedAttachment ? content : null };
-}
diff --git a/packages/frontend/src/lib/cache-warm-storage.ts b/packages/frontend/src/lib/cache-warm-storage.ts
deleted file mode 100644
index 66a8c31..0000000
--- a/packages/frontend/src/lib/cache-warm-storage.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * LocalStorage persistence for the per-tab "prompt cache warming" toggle.
- *
- * Cache warming keeps a tab's provider prompt-cache warm while the tab is idle
- * by periodically replaying its exact cached conversation plus a trivial
- * throwaway turn (see `cache-warming.svelte.ts`). Whether warming is enabled is
- * a per-tab preference that must survive a browser reload.
- *
- * Why localStorage and not the backend `settings` table:
- * - It's a per-device UI preference, not domain state — the same precedent as
- * `dispatch-sidebar-panels`, `dispatch-theme`, and `dispatch-api-url`.
- * - No backend round-trip on every toggle.
- * - Warming itself is a frontend-driven timer; keeping its on/off flag on the
- * frontend keeps the whole feature self-contained.
- *
- * Shape: a single JSON object mapping `tabId -> boolean` under one key, so a
- * closed tab's stale entry is cheap and easy to prune.
- */
-
-const LS_KEY = "dispatch-cache-warming";
-
-type WarmMap = Record<string, boolean>;
-
-/** Read the whole tab→enabled map. Never throws; returns {} on any failure. */
-function readMap(): WarmMap {
- try {
- const raw = localStorage.getItem(LS_KEY);
- if (!raw) return {};
- const parsed: unknown = JSON.parse(raw);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
- const out: WarmMap = {};
- for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
- if (typeof v === "boolean") out[k] = v;
- }
- return out;
- } catch {
- // localStorage unavailable (private mode / restricted context) or corrupt
- // write from a prior session. Degrade to "nothing persisted".
- return {};
- }
-}
-
-/** Persist the whole map. Best-effort — swallows quota / access errors. */
-function writeMap(map: WarmMap): void {
- try {
- localStorage.setItem(LS_KEY, JSON.stringify(map));
- } catch {
- // Best-effort: the in-memory store remains the source of truth for the
- // current session even if persistence fails.
- }
-}
-
-/** Whether cache warming is enabled for `tabId` (default: false). */
-export function loadCacheWarmEnabled(tabId: string): boolean {
- return readMap()[tabId] === true;
-}
-
-/**
- * Persist the warming toggle for one tab. Writing `false` removes the entry
- * (default is off, so absence is the natural "disabled" representation and
- * keeps stale tabs from accumulating).
- */
-export function saveCacheWarmEnabled(tabId: string, enabled: boolean): void {
- const map = readMap();
- if (enabled) map[tabId] = true;
- else delete map[tabId];
- writeMap(map);
-}
-
-/** Drop a tab's persisted toggle entirely (called when the tab is closed). */
-export function clearCacheWarmEnabled(tabId: string): void {
- const map = readMap();
- if (tabId in map) {
- delete map[tabId];
- writeMap(map);
- }
-}
diff --git a/packages/frontend/src/lib/cache-warming.svelte.ts b/packages/frontend/src/lib/cache-warming.svelte.ts
deleted file mode 100644
index 0253c08..0000000
--- a/packages/frontend/src/lib/cache-warming.svelte.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-/**
- * Prompt-cache WARMING — frontend timer/orchestration store.
- *
- * Keeps a tab's provider prompt-cache warm while the tab is IDLE by firing a
- * cheap "warm" request (`POST /chat/warm`) on a repeating ~4-minute cadence.
- * The backend replays the tab's EXACT cached prefix plus one trivial throwaway
- * turn (see `Agent.warmCache`), which registers a cache READ and refreshes the
- * provider's ~5-min prompt-cache TTL so the user's next real message lands on a
- * warm cache.
- *
- * Lifecycle (driven by the tab store via the `onTurn*` / `onUserMessage` hooks):
- * - A turn ENDS (tab goes idle) → arm: schedule a fire in 4 minutes.
- * - The timer fires → warm, then re-arm 4 minutes out
- * (repeats; resets the countdown each
- * cycle).
- * - A turn is ONGOING (generation active) → never fires; the pending timer is
- * cancelled.
- * - The user sends a real message → disable+reset the timer immediately;
- * the turn it starts re-arms warming
- * once it ends.
- *
- * CRITICAL: the warming request is debug-only. Its cache data is surfaced ONLY
- * as a warming-specific "Last request" percentage here — it is NEVER folded
- * into the real Cache Rate metric, never persisted, never counted toward
- * context. The backend route returns just the request's `usage`; nothing else.
- */
-
-import type { AgentModelEntry } from "@dispatch/core/src/types/index.js";
-import {
- clearCacheWarmEnabled,
- loadCacheWarmEnabled,
- saveCacheWarmEnabled,
-} from "./cache-warm-storage.js";
-import { config } from "./config.js";
-
-/** Re-warm cadence. Comfortably under Claude's ~5-min prompt-cache expiry. */
-export const WARM_INTERVAL_MS = 4 * 60 * 1000;
-
-/** Per-tab request parameters the warm POST needs (resolved from the tab). */
-export interface WarmRequestParams {
- keyId: string | null;
- modelId: string | null;
- agentModels: AgentModelEntry[] | null;
- /**
- * The SAME reasoning effort the next real turn would use. It drives the
- * Anthropic thinking providerOptions, which is a message-cache key — warming
- * must match it so it refreshes the bucket the real message reads.
- */
- reasoningEffort: string | null;
-}
-
-/** Reactive, per-tab warming UI state (read by the Chat Settings debug strip). */
-export interface WarmState {
- /** User toggle (persisted per-tab in localStorage). */
- enabled: boolean;
- /** Epoch ms of the next scheduled fire, or null when not armed. */
- nextFireAt: number | null;
- /**
- * Cache-read % of the most recent warming request (0–100), or null if it
- * has never fired this session. Drives the "-%" → number display.
- */
- lastPct: number | null;
- /** Last warming error (provider/network), surfaced in the debug strip. */
- error: string | null;
- /** True while a warm request is in flight. */
- firing: boolean;
-}
-
-function defaultState(enabled: boolean): WarmState {
- return { enabled, nextFireAt: null, lastPct: null, error: null, firing: false };
-}
-
-function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
- if (inputTokens <= 0) return 0;
- return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100);
-}
-
-export function createCacheWarmingStore() {
- // Reactive per-tab state. Nested mutation is reactive via Svelte 5 proxies;
- // new keys are assigned wholesale (also reactive).
- const states = $state<Record<string, WarmState>>({});
- // Ticking clock so the countdown display refreshes once per second. Only
- // ticked while at least one tab is armed (see (re)startTicker).
- let now = $state(Date.now());
-
- // Non-reactive bookkeeping (timers, in-flight tokens, running set, resolver).
- const fireTimers = new Map<string, ReturnType<typeof setTimeout>>();
- const fireTokens = new Map<string, number>();
- const runningTabs = new Set<string>();
- let ticker: ReturnType<typeof setInterval> | null = null;
- let resolveParams: ((tabId: string) => WarmRequestParams | null) | null = null;
-
- function ensure(tabId: string): WarmState {
- let s = states[tabId];
- if (!s) {
- s = defaultState(loadCacheWarmEnabled(tabId));
- states[tabId] = s;
- }
- return s;
- }
-
- function anyArmed(): boolean {
- for (const s of Object.values(states)) {
- if (s.nextFireAt !== null) return true;
- }
- return false;
- }
-
- function startTickerIfNeeded(): void {
- if (ticker !== null) return;
- if (typeof setInterval !== "function") return;
- ticker = setInterval(() => {
- now = Date.now();
- // Self-stop once nothing is armed, so we don't tick forever.
- if (!anyArmed()) stopTicker();
- }, 1000);
- }
-
- function stopTicker(): void {
- if (ticker !== null) {
- clearInterval(ticker);
- ticker = null;
- }
- }
-
- function clearFireTimer(tabId: string): void {
- const t = fireTimers.get(tabId);
- if (t !== undefined) {
- clearTimeout(t);
- fireTimers.delete(tabId);
- }
- }
-
- /** Cancel any pending fire / in-flight request and clear the countdown. */
- function cancel(tabId: string): void {
- clearFireTimer(tabId);
- // Invalidate any in-flight warm so its late result is ignored.
- fireTokens.set(tabId, (fireTokens.get(tabId) ?? 0) + 1);
- const s = states[tabId];
- if (s) s.nextFireAt = null;
- if (!anyArmed()) stopTicker();
- }
-
- /** Schedule the next fire 4 minutes out — only when enabled AND idle. */
- function arm(tabId: string): void {
- const s = ensure(tabId);
- if (!s.enabled) return;
- if (runningTabs.has(tabId)) return;
- clearFireTimer(tabId);
- s.nextFireAt = Date.now() + WARM_INTERVAL_MS;
- startTickerIfNeeded();
- if (typeof setTimeout === "function") {
- fireTimers.set(
- tabId,
- setTimeout(() => {
- fireTimers.delete(tabId);
- void fire(tabId);
- }, WARM_INTERVAL_MS),
- );
- }
- }
-
- /** Perform one warming request, then (if still eligible) re-arm. */
- async function fire(tabId: string): Promise<void> {
- const s = ensure(tabId);
- if (!s.enabled || runningTabs.has(tabId) || s.firing) {
- return;
- }
- const token = (fireTokens.get(tabId) ?? 0) + 1;
- fireTokens.set(tabId, token);
- const params = resolveParams?.(tabId) ?? null;
-
- s.firing = true;
- s.error = null;
- // Clear the countdown while the request is in flight.
- s.nextFireAt = null;
- try {
- const res = await fetch(`${config.apiBase}/chat/warm`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId,
- ...(params?.keyId ? { keyId: params.keyId } : {}),
- ...(params?.modelId ? { modelId: params.modelId } : {}),
- ...(params?.agentModels ? { agentModels: params.agentModels } : {}),
- ...(params?.reasoningEffort ? { reasoningEffort: params.reasoningEffort } : {}),
- }),
- });
- // A newer cancel/fire superseded this request — drop its result so it
- // can't clobber fresher state (e.g. user sent a real message meanwhile).
- if (fireTokens.get(tabId) !== token) return;
-
- if (!res.ok) {
- let msg = `warm failed (HTTP ${res.status})`;
- try {
- const body = (await res.json()) as { error?: string };
- if (body?.error) msg = body.error;
- } catch {
- /* non-JSON error body — keep the HTTP status message */
- }
- s.error = msg;
- } else {
- const data = (await res.json()) as {
- usage?: { inputTokens?: number; cacheReadTokens?: number };
- };
- const u = data.usage ?? {};
- s.lastPct = computeCachePct(u.inputTokens ?? 0, u.cacheReadTokens ?? 0);
- s.error = null;
- }
- } catch (err) {
- if (fireTokens.get(tabId) !== token) return;
- s.error = err instanceof Error ? err.message : String(err);
- } finally {
- if (fireTokens.get(tabId) === token) {
- s.firing = false;
- // Re-arm for the next cycle (resets the 4-min countdown), but only
- // if still enabled and the tab is still idle.
- if (s.enabled && !runningTabs.has(tabId)) arm(tabId);
- else if (!anyArmed()) stopTicker();
- }
- }
- }
-
- // ─── Public lifecycle hooks (called by the tab store) ────────────
-
- /**
- * Register the resolver the store uses to fetch a tab's request params
- * (key/model/agentModels) at fire time. Called once by the tab store.
- */
- function setRequestResolver(fn: (tabId: string) => WarmRequestParams | null): void {
- resolveParams = fn;
- }
-
- /** Seed a tab's state from persistence. Arms immediately if enabled+idle. */
- function initTab(tabId: string): void {
- const s = ensure(tabId);
- if (s.enabled && !runningTabs.has(tabId) && s.nextFireAt === null) {
- arm(tabId);
- }
- }
-
- /** Toggle warming for a tab (persisted). Arms or cancels accordingly. */
- function setEnabled(tabId: string, enabled: boolean): void {
- const s = ensure(tabId);
- s.enabled = enabled;
- saveCacheWarmEnabled(tabId, enabled);
- if (enabled) arm(tabId);
- else cancel(tabId);
- }
-
- /** A turn started / generation is active — never warm during a turn. */
- function onTurnActive(tabId: string): void {
- runningTabs.add(tabId);
- cancel(tabId);
- }
-
- /** A turn ended (tab idle) — re-arm the 4-minute countdown if enabled. */
- function onTurnEnded(tabId: string): void {
- runningTabs.delete(tabId);
- const s = ensure(tabId);
- if (s.enabled) arm(tabId);
- }
-
- /**
- * The user sent a real message — disable+reset the timer immediately. The
- * turn this message starts will re-arm warming via `onTurnEnded` once it
- * settles, so the real message lands on a cache with no throwaway turns.
- */
- function onUserMessage(tabId: string): void {
- cancel(tabId);
- }
-
- /** Forget a closed tab's timers/state. */
- function removeTab(tabId: string): void {
- cancel(tabId);
- fireTimers.delete(tabId);
- fireTokens.delete(tabId);
- runningTabs.delete(tabId);
- delete states[tabId];
- if (!anyArmed()) stopTicker();
- }
-
- /**
- * Forget a tab AND drop its persisted preference — for an explicit user
- * close/archive. (`removeTab` keeps the persisted flag so an ephemeral
- * idle-cleanup or a later reopen restores the user's choice.)
- */
- function forgetTab(tabId: string): void {
- removeTab(tabId);
- clearCacheWarmEnabled(tabId);
- }
-
- /** Reactive state for a tab (creates a default-off entry if absent). */
- function stateFor(tabId: string | null | undefined): WarmState {
- if (!tabId) return defaultState(false);
- return ensure(tabId);
- }
-
- return {
- setRequestResolver,
- initTab,
- setEnabled,
- onTurnActive,
- onTurnEnded,
- onUserMessage,
- removeTab,
- forgetTab,
- stateFor,
- /** Reactive ticking clock (epoch ms) for countdown rendering. */
- get now() {
- return now;
- },
- // Exposed for tests to drive a fire without waiting 4 minutes.
- fireNow: fire,
- };
-}
-
-export const cacheWarming = createCacheWarmingStore();
diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte
deleted file mode 100644
index f1c9cdf..0000000
--- a/packages/frontend/src/lib/components/AgentBuilder.svelte
+++ /dev/null
@@ -1,806 +0,0 @@
-<script module>
-// Session-level cache for models per key; avoids refetching across component instances
-const modelCache = new Map();
-</script>
-
-<script lang="ts">
- import {
- DEFAULT_REASONING_EFFORT,
- REASONING_EFFORTS,
- REASONING_EFFORT_LABELS,
- } from "@dispatch/core/src/types/index.js";
- import { config } from "../config.js";
- import { router } from "../router.svelte.js";
- import type { KeyInfo } from "../types.js";
- import SkillsBrowser from "./SkillsBrowser.svelte";
- import ToolPermissions from "./ToolPermissions.svelte";
-
- interface AgentModelEntry {
- key_id: string;
- model_id: string;
- effort?: string;
- }
-
- interface AgentDefinition {
- name: string;
- description: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- scope: string;
- slug: string;
- cwd?: string;
- is_subagent?: boolean;
- }
-
- interface DirEntry {
- label: string;
- path: string;
- scope: string;
- }
-
- const { keys = [] }: { keys?: KeyInfo[] } = $props();
-
-
-
- // Portal action (escape stacking context)
- function portal(node: HTMLElement) {
- document.body.appendChild(node);
- return {
- destroy() {
- node.remove();
- },
- };
- }
-
- // State
- let agents = $state<AgentDefinition[]>([]);
- let dirs = $state<DirEntry[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
-
- // Editor state
- let editing = $state(false);
- let editingSlug = $state<string | null>(null); // null = new agent
-
- let formName = $state("");
- let formDescription = $state("");
- let formScope = $state("global");
- let formCwd = $state("");
- let cwdExists = $state<boolean | null>(null);
- let cwdResolved = $state<string | null>(null);
- let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
- let formSkills = $state<Set<string>>(new Set());
- let formTools = $state<Set<string>>(new Set());
- let formModels = $state<AgentModelEntry[]>([]);
- let formIsSubagent = $state(false);
-
- // Model selection modal state
- let modelModalIndex = $state<number | null>(null);
- let modelModalType = $state<"key" | "model">("key");
- let modalAvailableModels = $state<string[]>([]);
- let modalLoadingModels = $state(false);
- let modalModelError = $state<string | null>(null);
-
- // Drag-and-drop reorder state
- let dragIndex = $state<number | null>(null);
- let dragOverIndex = $state<number | null>(null);
-
- // Delete confirm
- let deletingAgent = $state<AgentDefinition | null>(null);
-
- function slugify(name: string): string {
- return name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "")
- || "agent";
- }
-
- async function fetchAgents() {
- loading = true;
- error = null;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- agents = data.agents ?? [];
- dirs = data.dirs ?? [];
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch agents";
- } finally {
- loading = false;
- }
- }
-
- function handleSkillToggle(key: string, checked: boolean) {
- const next = new Set(formSkills);
- if (checked) next.add(key);
- else next.delete(key);
- formSkills = next;
- }
-
- function startNewAgent() {
- formReady = false;
- editingSlug = null;
- formName = "";
- formDescription = "";
- formScope = dirs[0]?.scope ?? "global";
- formCwd = "";
- formSkills = new Set();
- formTools = new Set();
- formModels = [];
- formIsSubagent = true;
- editing = true;
- // Allow the effect to skip the initial population
- setTimeout(() => { formReady = true; }, 0);
- }
-
- function startEdit(agent: AgentDefinition) {
- formReady = false;
- editingSlug = agent.slug;
- formName = agent.name;
- formDescription = agent.description;
- formScope = agent.scope;
- formCwd = agent.cwd ?? "";
- formSkills = new Set(agent.skills);
- formTools = new Set(agent.tools);
- formModels = agent.models.map((m) => ({ ...m }));
- formIsSubagent = agent.is_subagent ?? false;
- editing = true;
- // Allow the effect to skip the initial population
- setTimeout(() => { formReady = true; }, 0);
- }
-
- function cancelEdit() {
- // Flush any pending debounced save before leaving
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- saveAgent();
- }
- formReady = false;
- editing = false;
- editingSlug = null;
- }
-
- function handleToolToggle(id: string, checked: boolean) {
- const next = new Set(formTools);
- if (checked) next.add(id);
- else next.delete(id);
- formTools = next;
- }
-
- function addModelEntry() {
- formModels = [...formModels, { key_id: "", model_id: "" }];
- }
-
- function removeModelEntry(i: number) {
- formModels = formModels.filter((_, idx) => idx !== i);
- }
-
- function setEffortEntry(i: number, effort: string) {
- formModels = formModels.map((m, idx) => {
- if (idx !== i) return m;
- // Empty string = "inherit" (no per-model override). Strip the key so
- // the saved TOML omits `effort` and the call site falls back to the
- // per-tab selector / default.
- if (!effort) {
- const { effort: _dropped, ...rest } = m;
- return rest;
- }
- return { ...m, effort };
- });
- }
-
- async function openKeyModal(i: number) {
- modelModalIndex = i;
- modelModalType = "key";
- }
-
- async function openModelModal(i: number) {
- const entry = formModels[i];
- if (!entry || !entry.key_id) return;
- modelModalIndex = i;
- modelModalType = "model";
- modalModelError = null;
- modalAvailableModels = [];
-
- if (modelCache.has(entry.key_id)) {
- modalAvailableModels = modelCache.get(entry.key_id)!;
- return;
- }
-
- modalLoadingModels = true;
- try {
- const res = await fetch(
- `${config.apiBase}/models/available?keyId=${encodeURIComponent(entry.key_id)}`,
- );
- if (!res.ok) {
- const d = await res.json().catch(() => ({}));
- modalModelError = d.error ?? `HTTP ${res.status}`;
- return;
- }
- const d = await res.json();
- modalAvailableModels = d.models ?? [];
- modelCache.set(entry.key_id, modalAvailableModels);
- } catch (e) {
- modalModelError = e instanceof Error ? e.message : "Failed";
- } finally {
- modalLoadingModels = false;
- }
- }
-
- function selectKey(keyId: string) {
- if (modelModalIndex === null) return;
- const idx = modelModalIndex;
- formModels = formModels.map((m, i) =>
- i === idx ? { key_id: keyId, model_id: "" } : m,
- );
- modelModalIndex = null;
- // Immediately open model selection for the same row
- openModelModal(idx);
- }
-
- function selectModel(model: string) {
- if (modelModalIndex === null) return;
- formModels = formModels.map((m, i) =>
- i === modelModalIndex ? { ...m, model_id: model } : m,
- );
- modelModalIndex = null;
- }
-
- function closeModal() {
- modelModalIndex = null;
- }
-
- let formError = $state<string | null>(null);
- let saving = $state(false);
- let formReady = false;
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
- let saveAbort: AbortController | null = null;
-
- function isFormValid(): boolean {
- if (!formName.trim()) return false;
- if (formModels.length === 0) return false;
- if (formModels.some((m) => !m.key_id || !m.model_id)) return false;
- return true;
- }
-
- async function saveAgent() {
- if (!isFormValid()) return;
- formError = null;
-
- // Cancel any in-flight save
- saveAbort?.abort();
- const controller = new AbortController();
- saveAbort = controller;
-
- const payload: AgentDefinition = {
- name: formName.trim(),
- description: formDescription.trim(),
- skills: Array.from(formSkills),
- tools: Array.from(formTools),
- models: formModels,
- scope: formScope,
- slug: editingSlug ?? slugify(formName.trim()),
- ...(formCwd.trim() ? { cwd: formCwd.trim() } : {}),
- ...(formIsSubagent ? { is_subagent: true } : {}),
- };
-
- saving = true;
- try {
- const res = await fetch(`${config.apiBase}/agents`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- signal: controller.signal,
- });
- if (!res.ok) {
- const d = await res.json().catch(() => ({}));
- formError = d.error ?? `HTTP ${res.status}`;
- return;
- }
- // If this was a new agent, lock in the slug for future saves
- if (!editingSlug) {
- editingSlug = payload.slug;
- }
- await fetchAgents();
- } catch (e) {
- if (e instanceof DOMException && e.name === "AbortError") return;
- formError = e instanceof Error ? e.message : "Failed to save";
- } finally {
- if (saveAbort === controller) {
- saving = false;
- saveAbort = null;
- }
- }
- }
-
- // Check if CWD directory exists (debounced)
- $effect(() => {
- const cwd = formCwd;
- if (!cwd.trim()) {
- cwdExists = null;
- cwdResolved = null;
- return;
- }
- if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
- cwdCheckTimer = setTimeout(async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd.trim())}`,
- );
- if (res.ok) {
- const data = await res.json();
- cwdExists = data.exists ?? false;
- cwdResolved = data.resolved ?? null;
- }
- } catch {
- cwdExists = null;
- cwdResolved = null;
- }
- }, 300);
- });
-
- // Auto-save with debounce whenever form fields change
- $effect(() => {
- // Read all reactive form fields to subscribe
- // noinspection: intentionally unused — reading these values subscribes the effect to them
- void [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels, formIsSubagent];
- if (!formReady || !editing) return;
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- saveAgent();
- }, 600);
- });
-
- async function deleteAgent(agent: AgentDefinition) {
- try {
- // Prevent auto-save from re-creating the deleted agent
- formReady = false;
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- saveAbort?.abort();
-
- const res = await fetch(
- `${config.apiBase}/agents/${encodeURIComponent(agent.slug)}?scope=${encodeURIComponent(agent.scope)}`,
- { method: "DELETE" },
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- deletingAgent = null;
- editing = false;
- editingSlug = null;
- await fetchAgents();
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to delete";
- }
- }
-
- const hasName = $derived(formName.trim().length > 0);
-
- const globalAgents = $derived(agents.filter((a) => a.scope === "global"));
- const projectAgents = $derived(agents.filter((a) => a.scope !== "global"));
-
- // Fetch on mount
- $effect(() => {
- fetchAgents();
- });
-</script>
-
-<div class="flex flex-col flex-1 overflow-hidden">
- <!-- Page header -->
- <div class="flex items-center gap-4 px-6 py-4 border-b border-base-300 bg-base-200 shrink-0">
- <h1 class="text-xl font-bold flex-1">Agent Settings</h1>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- onclick={() => { if (editing) { cancelEdit(); } else { router.navigate("dashboard"); } }}
- >
- {editing ? '← Back to Agent Settings' : '← Back to Dashboard'}
- </button>
- </div>
-
- <div class="flex-1 overflow-y-auto px-6 py-6">
- {#if error}
- <div class="alert alert-error mb-4">{error}</div>
- {/if}
-
- {#if editing}
- <!-- Agent Editor Form -->
- <div class="card bg-base-200 shadow-md">
- <div class="card-body gap-4">
- <h2 class="card-title">{editingSlug ? "Edit Agent" : "New Agent"}</h2>
-
- {#if formError}
- <div class="alert alert-error text-sm">{formError}</div>
- {/if}
-
- <!-- Name -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-name">
- <span class="label-text font-semibold">Name *</span>
- </label>
- <input
- id="agent-name"
- type="text"
- class="input input-bordered"
- placeholder="my-agent"
- bind:value={formName}
- />
- {#if formName}
- <span class="text-xs text-base-content/50">slug: {slugify(formName)}</span>
- {/if}
- </div>
-
- <fieldset disabled={!hasName} class="contents">
- <!-- Description -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-desc">
- <span class="label-text font-semibold">Description</span>
- </label>
- <input
- id="agent-desc"
- type="text"
- class="input input-bordered"
- placeholder="What does this agent do?"
- bind:value={formDescription}
- />
- </div>
-
- <!-- Is Subagent -->
- <div class="form-control">
- <label class="label cursor-pointer justify-start gap-3 py-1">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={formIsSubagent}
- />
- <div>
- <span class="label-text font-semibold">Is Subagent</span>
- <p class="text-xs text-base-content/50">Subagents are hidden from Chat Settings and can only be used by other agents.</p>
- </div>
- </label>
- </div>
-
- <!-- Scope -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-scope">
- <span class="label-text font-semibold">Scope</span>
- </label>
- <select id="agent-scope" class="select select-bordered" bind:value={formScope}>
- <option value="global">Global</option>
- {#each dirs.filter((d) => d.scope !== "global") as dir}
- <option value={dir.scope}>{dir.label} ({dir.path})</option>
- {/each}
- </select>
- </div>
-
- <!-- Working Directory -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-cwd">
- <span class="label-text font-semibold">Working Directory</span>
- </label>
- <div class="flex items-center gap-2">
- <input
- id="agent-cwd"
- type="text"
- class="input input-bordered font-mono text-sm flex-1"
- placeholder="default (project root)"
- bind:value={formCwd}
- />
- {#if formCwd.trim()}
- {#if cwdExists === true}
- <span class="text-success text-lg" title="Directory exists">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-lg" title="Directory does not exist (will be created)">&#x2716;</span>
- {:else}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {/if}
- </div>
- {#if cwdExists === false && formCwd.trim()}
- <span class="text-xs text-warning">Directory will be created automatically on first message.</span>
- {/if}
- {#if cwdResolved && formCwd.trim() && cwdResolved !== formCwd.trim()}
- <span class="text-xs text-base-content/50 font-mono">{cwdResolved}</span>
- {/if}
- {#if !formCwd.trim()}
- <span class="text-xs text-base-content/50">Absolute or relative path. Relative paths resolve against the parent agent's working directory, or the project root.</span>
- {/if}
- </div>
-
- <!-- Models -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Models *</span>
- </div>
- <div class="flex flex-col gap-2">
- {#each formModels as entry, i (i)}
- <div
- class="flex items-center gap-2 rounded p-1 transition-colors {dragOverIndex === i ? 'bg-primary/10 border border-primary/30' : ''}"
- draggable="true"
- role="listitem"
- ondragstart={(e) => {
- dragIndex = i;
- if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
- }}
- ondragover={(e) => {
- e.preventDefault();
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
- dragOverIndex = i;
- }}
- ondragleave={() => {
- if (dragOverIndex === i) dragOverIndex = null;
- }}
- ondrop={(e) => {
- e.preventDefault();
- if (dragIndex !== null && dragIndex !== i) {
- const reordered = [...formModels];
- const moved = reordered.splice(dragIndex, 1)[0];
- if (moved) {
- reordered.splice(i, 0, moved);
- formModels = reordered;
- }
- }
- dragIndex = null;
- dragOverIndex = null;
- }}
- ondragend={() => { dragIndex = null; dragOverIndex = null; }}
- >
- <span class="badge badge-sm badge-neutral font-mono w-6 shrink-0 cursor-grab">{i + 1}</span>
- <button
- type="button"
- class="btn btn-sm btn-outline flex-1 font-mono truncate"
- onclick={() => openKeyModal(i)}
- >
- {entry.key_id || "Select Key"}
- </button>
- <button
- type="button"
- class="btn btn-sm btn-outline flex-1 font-mono truncate"
- onclick={() => openModelModal(i)}
- disabled={!entry.key_id}
- >
- {entry.model_id || "Select Model"}
- </button>
- <select
- class="select select-bordered select-sm shrink-0 w-36"
- title="Reasoning effort for this model. 'Inherit' uses the per-tab selector / default."
- value={entry.effort ?? ""}
- onchange={(e) => setEffortEntry(i, e.currentTarget.value)}
- >
- <option value="">Inherit ({REASONING_EFFORT_LABELS[DEFAULT_REASONING_EFFORT]})</option>
- {#each REASONING_EFFORTS as effort}
- <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
- {/each}
- </select>
- <button
- type="button"
- class="btn btn-sm btn-ghost text-error"
- onclick={() => removeModelEntry(i)}
- aria-label="Remove model entry"
- >
- ✕
- </button>
- </div>
- {/each}
- </div>
- <button
- type="button"
- class="btn btn-sm btn-ghost w-fit"
- onclick={addModelEntry}
- >
- + Add Model
- </button>
- </div>
-
- <!-- Tools -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Tools</span>
- </div>
- <ToolPermissions
- checkedTools={formTools}
- onToolToggle={handleToolToggle}
- />
- </div>
-
- <!-- Skills -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Skills</span>
- </div>
- <SkillsBrowser
- apiBase={config.apiBase}
- checkedSkills={formSkills}
- onSkillToggle={handleSkillToggle}
- />
- </div>
-
- <!-- Actions -->
- <div class="card-actions justify-between items-center pt-2">
- {#if editingSlug && !(editingSlug === "default" && formScope === "global")}
- <button
- type="button"
- class="btn btn-error btn-ghost"
- onclick={() => {
- const agent = agents.find((a) => a.slug === editingSlug && a.scope === formScope);
- if (agent) deletingAgent = agent;
- }}
- >Delete</button>
- {:else}
- <div></div>
- {/if}
- <div class="flex items-center gap-2">
- {#if saving}
- <span class="text-xs text-base-content/50">Saving...</span>
- {/if}
- </div>
- </div>
- </fieldset>
- </div>
- </div>
- {:else}
- <!-- Agent List -->
- {#if loading}
- <div class="flex justify-center py-16">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else}
- <!-- Global Agents -->
- <section class="mb-8">
- <h2 class="text-lg font-semibold mb-3">Global Agents</h2>
- <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
- {#each globalAgents as agent}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
- onclick={() => startEdit(agent)}
- >
- <div class="card-body p-4 gap-2">
- <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
- {#if agent.description}
- <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
- {/if}
- <div class="flex gap-2 flex-wrap">
- <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
- </div>
- </div>
- </button>
- {/each}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer border-2 border-dashed border-base-content/20"
- onclick={startNewAgent}
- >
- <div class="card-body p-4 items-center justify-center">
- <span class="text-2xl text-base-content/30">+</span>
- <span class="text-sm text-base-content/50">New Agent</span>
- </div>
- </button>
- </div>
- </section>
-
- <!-- Project Agents -->
- {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")}
- <section>
- <h2 class="text-lg font-semibold mb-3">Project Agents</h2>
- {#if projectAgents.length === 0}
- <p class="text-base-content/50 italic text-sm">No project agents yet.</p>
- {:else}
- <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
- {#each projectAgents as agent}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
- onclick={() => startEdit(agent)}
- >
- <div class="card-body p-4 gap-2">
- <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
- {#if agent.description}
- <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
- {/if}
- <p class="text-xs text-base-content/40 font-mono truncate">{agent.scope}</p>
- <div class="flex gap-2 flex-wrap">
- <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
- </div>
- </div>
- </button>
- {/each}
- </div>
- {/if}
- </section>
- {/if}
- {/if}
- {/if}
- </div>
-</div>
-
-<!-- Key selection modal -->
-{#if modelModalIndex !== null && modelModalType === "key"}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Key</h3>
- <div class="mt-4 flex flex-col gap-2">
- {#each keys as key}
- <button
- type="button"
- class="btn {formModels[modelModalIndex ?? 0]?.key_id === key.id ? 'btn-primary' : 'btn-ghost'} justify-start text-base"
- onclick={() => selectKey(key.id)}
- >
- <span class="font-mono">{key.id}</span>
- <span class="badge ml-auto">{key.provider}</span>
- <span class="badge {key.status === 'active' ? 'badge-success' : 'badge-error'}">{key.status}</span>
- </button>
- {/each}
- </div>
- <div class="modal-action">
- <button type="button" class="btn" onclick={closeModal}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
- </div>
-{/if}
-
-<!-- Model selection modal -->
-{#if modelModalIndex !== null && modelModalType === "model"}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Model</h3>
- {#if modalLoadingModels}
- <div class="flex justify-center py-8">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else if modalModelError}
- <div class="alert alert-error mt-4 text-base">
- <span>{modalModelError}</span>
- </div>
- {:else}
- <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto">
- {#each modalAvailableModels as model}
- <button
- type="button"
- class="btn {formModels[modelModalIndex ?? 0]?.model_id === model ? 'btn-primary' : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
- </div>
- {/if}
- <div class="modal-action">
- <button type="button" class="btn" onclick={closeModal}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
- </div>
-{/if}
-
-<!-- Delete confirm modal -->
-{#if deletingAgent !== null}
- {@const agentToDelete = deletingAgent}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-lg">Delete Agent</h3>
- <p class="py-4">
- Are you sure you want to delete <span class="font-mono font-semibold">{agentToDelete.name}</span>? This cannot be undone.
- </p>
- <div class="modal-action">
- <button type="button" class="btn btn-ghost" onclick={() => { deletingAgent = null; }}>Cancel</button>
- <button
- type="button"
- class="btn btn-error"
- onclick={() => deleteAgent(agentToDelete)}
- >Delete</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => { deletingAgent = null; }} aria-label="Close modal"></button>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/CacheRatePanel.svelte b/packages/frontend/src/lib/components/CacheRatePanel.svelte
deleted file mode 100644
index 88985a0..0000000
--- a/packages/frontend/src/lib/components/CacheRatePanel.svelte
+++ /dev/null
@@ -1,124 +0,0 @@
-<script lang="ts">
-import type { CacheStats } from "../types.js";
-
-const {
- cacheStats = null,
- tabTitle = null,
-}: {
- cacheStats?: CacheStats | null;
- tabTitle?: string | null;
-} = $props();
-
-// Cache hit rate = cached-read tokens / total prompt tokens. `inputTokens` is
-// the TOTAL prompt (fresh + cache read + cache write), so this is the share of
-// the prompt that was served from Anthropic's prompt cache.
-function rate(read: number, totalInput: number): number {
- if (totalInput <= 0) return 0;
- return Math.max(0, Math.min(1, read / totalInput));
-}
-
-// For caching, a HIGH hit rate is GOOD — invert the usual color thresholds.
-function rateClass(r: number): string {
- if (r >= 0.7) return "progress-success";
- if (r >= 0.3) return "progress-warning";
- return "progress-error";
-}
-
-function fmt(n: number): string {
- return n.toLocaleString();
-}
-
-const hitRate = $derived(cacheStats ? rate(cacheStats.cacheReadTokens, cacheStats.inputTokens) : 0);
-const hitPct = $derived(Math.round(hitRate * 100));
-const uncached = $derived(
- cacheStats
- ? Math.max(0, cacheStats.inputTokens - cacheStats.cacheReadTokens - cacheStats.cacheWriteTokens)
- : 0,
-);
-const lastHitPct = $derived(
- cacheStats?.last
- ? Math.round(rate(cacheStats.last.cacheReadTokens, cacheStats.last.inputTokens) * 100)
- : 0,
-);
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- {#if !cacheStats || cacheStats.requests === 0}
- <p class="text-xs text-base-content/50">
- No cache data yet. Send a message to a Claude model — prompt-cache usage
- appears here after the first response.
- </p>
- {:else}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-2">
- <span class="text-xs font-semibold">Cache Hit Rate</span>
- {#if tabTitle}
- <span class="badge badge-xs badge-ghost">{tabTitle}</span>
- {/if}
- <span class="badge badge-xs ml-auto whitespace-nowrap">{cacheStats.requests} req</span>
- </div>
-
- <!-- Headline cumulative hit rate -->
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Session (this tab)</span>
- <span class="text-xs font-mono">{hitPct}%</span>
- </div>
- <progress
- class="progress w-full h-2 {rateClass(hitRate)}"
- value={hitPct}
- max="100"
- ></progress>
- </div>
-
- <!-- Most recent request -->
- {#if cacheStats.last}
- <div class="flex flex-col gap-0.5 mt-2">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Last request</span>
- <span class="text-xs font-mono">{lastHitPct}%</span>
- </div>
- <progress
- class="progress w-full h-2 {rateClass(lastHitPct / 100)}"
- value={lastHitPct}
- max="100"
- ></progress>
- </div>
- {/if}
- </div>
-
- <!-- Token breakdown (cumulative, this tab) -->
- <div class="bg-base-200 rounded-lg p-2">
- <div class="text-xs font-semibold mb-1.5">Tokens (cumulative)</div>
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-success badge-soft mr-1">read</span>Cache hits
- </span>
- <span class="text-xs font-mono">{fmt(cacheStats.cacheReadTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-warning badge-soft mr-1">write</span>Cache writes
- </span>
- <span class="text-xs font-mono">{fmt(cacheStats.cacheWriteTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-error badge-soft mr-1">fresh</span>Uncached input
- </span>
- <span class="text-xs font-mono">{fmt(uncached)}</span>
- </div>
- <div class="border-t border-base-300 my-0.5"></div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Total input</span>
- <span class="text-xs font-mono">{fmt(cacheStats.inputTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Output</span>
- <span class="text-xs font-mono">{fmt(cacheStats.outputTokens)}</span>
- </div>
- </div>
- </div>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
deleted file mode 100644
index f3eadf7..0000000
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ /dev/null
@@ -1,396 +0,0 @@
-<script lang="ts">
-import {
- ACCEPTED_PDF_MEDIA_TYPE,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
-} from "@dispatch/core/src/models/attachments.js";
-import {
- type AttachmentKind,
- computeTokenDeletion,
- generateTokenId,
- makeAttachmentToken,
- parseDraft,
- type StagedAttachment,
-} from "../attachment-tokens.js";
-import { computeContextUsage } from "../context-window.js";
-import { tabStore } from "../tabs.svelte.js";
-
-const {
- contextLimit = null,
- imageSupport = null,
-}: {
- contextLimit?: number | null;
- // Image/PDF INPUT capability for the active model, or `null` when unknown
- // (catalog offline / unsupported provider) — null means "can't verify"
- // (optimistic allow), not a hard no.
- imageSupport?: { image: boolean; pdf: boolean } | null;
-} = $props();
-
-const MAX_LINES = 7;
-
-let inputEl: HTMLTextAreaElement | undefined;
-// Transient error shown when a paste is rejected (bad type / too large / too
-// many). Cleared on the next successful paste or any keystroke.
-let pasteError = $state<string | null>(null);
-
-const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
-const tabId = $derived(tabStore.activeTab?.id ?? "");
-// The current input text lives on the active tab (in-memory draft), so
-// switching tabs saves the current draft and restores the target tab's text
-// automatically — drafts are never lost or clobbered by tab switching.
-const inputValue = $derived(tabStore.activeTab?.draft ?? "");
-const attachments = $derived(tabStore.activeTab?.attachments ?? []);
-const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null);
-
-const isRunning = $derived(agentStatus === "running");
-// Lock input while this tab is mid-compaction: either it's the source
-// conversation being summarized, or it's the transient placeholder tab.
-const compactLocked = $derived(
- (tabStore.activeTab?.isCompacting ?? false) ||
- (tabStore.activeTab?.compactingSource ?? null) !== null ||
- (tabStore.activeTab?.compactionError ?? null) !== null,
-);
-const hasText = $derived(inputValue.trim().length > 0);
-const hasAttachments = $derived(attachments.length > 0);
-// While generating with an empty box, the primary action is "stop". With text
-// in the box, it stays "send" (the message is queued behind the live turn).
-const showStop = $derived(isRunning && !hasText && !hasAttachments);
-
-// ─── Attachment capability gating ──────────────────────────────
-// A definitive "no" from the catalog (imageSupport.image === false with an
-// image staged, or .pdf === false with a pdf staged) blocks the send so no
-// tokens are spent. Unknown capability (imageSupport === null) is permissive.
-const hasImageAttachment = $derived(attachments.some((a) => a.kind === "image"));
-const hasPdfAttachment = $derived(attachments.some((a) => a.kind === "pdf"));
-const imageBlocked = $derived(
- hasImageAttachment && imageSupport !== null && imageSupport.image === false,
-);
-const pdfBlocked = $derived(
- hasPdfAttachment && imageSupport !== null && imageSupport.pdf === false,
-);
-// Attachments require a fresh turn — they can't ride the queue path (which is
-// text-only), so block sending an attachment while the agent is generating.
-const attachmentsWhileRunning = $derived(hasAttachments && isRunning);
-
-const attachmentWarning = $derived.by(() => {
- if (pasteError) return pasteError;
- if (attachmentsWhileRunning)
- return "Wait for the current response to finish before sending images.";
- if (imageBlocked && pdfBlocked)
- return "The selected model doesn't support image or PDF input. Remove the attachments to send.";
- if (imageBlocked)
- return "The selected model doesn't support image input. Remove the image to send.";
- if (pdfBlocked) return "The selected model doesn't support PDF input. Remove the PDF to send.";
- return null;
-});
-
-// Send is blocked (but not the box) when an attachment is definitively
-// unsupported or when attachments are staged mid-generation.
-const sendBlocked = $derived(imageBlocked || pdfBlocked || attachmentsWhileRunning);
-
-const usage = $derived(computeContextUsage(cacheStats, contextLimit));
-const hasUsage = $derived((cacheStats?.last ?? null) !== null);
-
-// As the window fills, escalate color: calm → warning → danger. Mirrors the
-// Context Window sidebar view so the two displays agree.
-function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
-}
-
-// Compact token count for the slim bar (e.g. 12.3k, 1.2M). Full numbers live
-// in the sidebar's Context Window panel.
-function fmtCompact(n: number): string {
- if (n < 1000) return `${n}`;
- if (n < 1_000_000) {
- const k = n / 1000;
- return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`;
- }
- const m = n / 1_000_000;
- return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`;
-}
-
-$effect(() => {
- // Re-focus when switching tabs.
- void tabId;
- inputEl?.focus();
-});
-
-function resize() {
- const el = inputEl;
- if (!el) return;
- // Reset height so scrollHeight reflects the content's natural height.
- el.style.height = "auto";
- const style = getComputedStyle(el);
- const lineHeight = Number.parseFloat(style.lineHeight) || 20;
- const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
- const borderY =
- Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
- const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
- const next = Math.min(el.scrollHeight, maxHeight);
- el.style.height = `${next}px`;
- el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
-}
-
-// Re-run resize whenever the value changes (covers tab switches and
-// programmatic clears too).
-$effect(() => {
- // Touch inputValue so this effect tracks it.
- void inputValue;
- resize();
-});
-
-function handleInput(e: Event) {
- if (!tabId) return;
- pasteError = null;
- // setDraft also reconciles staged attachments against the surviving tokens,
- // so deleting a token (by any means) detaches its attachment.
- tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value);
-}
-
-function kindForMediaType(mediaType: string): AttachmentKind | null {
- if (isImageMediaType(mediaType)) return "image";
- if (isPdfMediaType(mediaType)) return "pdf";
- return null;
-}
-
-function readAsBase64(file: File): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const result = reader.result;
- if (typeof result !== "string") {
- reject(new Error("unexpected reader result"));
- return;
- }
- // Strip the `data:<mediaType>;base64,` prefix → bare base64.
- const comma = result.indexOf(",");
- resolve(comma === -1 ? result : result.slice(comma + 1));
- };
- reader.onerror = () => reject(reader.error ?? new Error("read failed"));
- reader.readAsDataURL(file);
- });
-}
-
-/** Insert `insert` at the textarea's caret, returning the new caret offset. */
-function insertAtCaret(insert: string): number {
- const el = inputEl;
- const text = inputValue;
- const start = el?.selectionStart ?? text.length;
- const end = el?.selectionEnd ?? text.length;
- const next = text.slice(0, start) + insert + text.slice(end);
- if (tabId) tabStore.setDraft(tabId, next);
- return start + insert.length;
-}
-
-async function handlePaste(e: ClipboardEvent) {
- if (!tabId) return;
- const items = e.clipboardData?.items;
- if (!items) return;
- const files: File[] = [];
- for (const item of items) {
- if (item.kind === "file") {
- const file = item.getAsFile();
- if (file) files.push(file);
- }
- }
- // No files in the clipboard → let the default text paste happen.
- if (files.length === 0) return;
- // We're handling at least one file; stop the browser from also pasting a
- // filename / image fallback into the textarea.
- e.preventDefault();
- pasteError = null;
-
- for (const file of files) {
- const kind = kindForMediaType(file.type);
- if (!kind) {
- pasteError = `Unsupported file type: ${file.type || "unknown"}. Allowed: PNG, JPEG, WebP, GIF, PDF.`;
- continue;
- }
- const current = tabStore.activeTab?.attachments ?? [];
- if (current.length >= MAX_ATTACHMENTS) {
- pasteError = `You can attach at most ${MAX_ATTACHMENTS} files per message.`;
- break;
- }
- const limit = kind === "pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES;
- if (file.size > limit) {
- const mb = Math.round(limit / (1024 * 1024));
- pasteError = `${kind === "pdf" ? "PDF" : "Image"} is too large (max ${mb} MB).`;
- continue;
- }
- try {
- const data = await readAsBase64(file);
- const id = generateTokenId();
- const mediaType = kind === "pdf" ? ACCEPTED_PDF_MEDIA_TYPE : file.type;
- const staged: StagedAttachment = {
- id,
- kind,
- mediaType,
- data,
- ...(file.name ? { name: file.name } : {}),
- };
- // Stage first, then insert the token — `setDraft` reconciles against
- // staged attachments, so the attachment must exist before its token
- // appears in the draft.
- tabStore.addAttachment(tabId, staged);
- const caret = insertAtCaret(makeAttachmentToken(kind, id));
- // Restore the caret after the value updates.
- requestAnimationFrame(() => {
- const el = inputEl;
- if (el) {
- el.focus();
- el.setSelectionRange(caret, caret);
- }
- });
- } catch {
- pasteError = "Failed to read the pasted file.";
- }
- }
-}
-
-function handleKeydown(e: KeyboardEvent) {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- submit();
- return;
- }
- if ((e.key === "Backspace" || e.key === "Delete") && inputEl && tabId) {
- // Atomic token delete: a single Backspace/Delete next to (or a selection
- // overlapping) a `【…】` token removes the whole token in one stroke.
- const result = computeTokenDeletion(
- inputValue,
- inputEl.selectionStart ?? 0,
- inputEl.selectionEnd ?? 0,
- e.key,
- );
- if (result) {
- e.preventDefault();
- tabStore.setDraft(tabId, result.text);
- requestAnimationFrame(() => {
- const el = inputEl;
- if (el) {
- el.focus();
- el.setSelectionRange(result.caret, result.caret);
- }
- });
- }
- }
-}
-
-function submit() {
- if (!tabId) return;
- // Block sending while this tab is mid-compaction (source or placeholder).
- if (compactLocked) return;
- const map = new Map(attachments.map((a) => [a.id, a] as const));
- const { displayText, content } = parseDraft(inputValue, map);
- const trimmed = displayText.trim();
- // Nothing to send (no text and no usable attachment).
- if (!trimmed && !content) return;
- // Don't send when a staged attachment is unsupported / mid-generation.
- if (sendBlocked) return;
- const text = trimmed || displayText;
- tabStore.setDraft(tabId, "");
- void tabStore.sendMessage(text, content ?? undefined);
-}
-
-function primaryAction() {
- if (showStop) {
- tabStore.stopGeneration(tabId);
- return;
- }
- submit();
-}
-</script>
-
-<div class="flex flex-col">
- {#if attachmentWarning}
- <div class="px-3 pt-2 text-xs text-warning flex items-start gap-1">
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 mt-0.5 shrink-0" aria-hidden="true">
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
- <line x1="12" y1="9" x2="12" y2="13"></line>
- <line x1="12" y1="17" x2="12.01" y2="17"></line>
- </svg>
- <span>{attachmentWarning}</span>
- </div>
- {/if}
- <!-- Top bar: expanding textarea + send/stop action -->
- <div class="flex items-end gap-2 px-3 pt-3 pb-2">
- <textarea
- bind:this={inputEl}
- value={inputValue}
- rows="1"
- placeholder={compactLocked
- ? "Compaction in progress…"
- : "Type a message... (paste an image or PDF to attach)"}
- disabled={compactLocked}
- class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
- onkeydown={handleKeydown}
- oninput={handleInput}
- onpaste={handlePaste}
- ></textarea>
- <!-- Single fixed-width button across all states so the layout never
- shifts when it morphs between Send and Stop. -->
- <button
- type="button"
- class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}"
- disabled={compactLocked || (!showStop && !hasText && !hasAttachments) || sendBlocked}
- onclick={primaryAction}
- title={showStop ? "Stop generation" : sendBlocked ? (attachmentWarning ?? "Cannot send") : "Send message"}
- >
- {#if showStop}
- <span class="loading loading-spinner loading-sm"></span>
- Stop
- {:else}
- Send
- {/if}
- </button>
- </div>
-
- <!-- Bottom bar: status icon · context progress · token count -->
- <div class="flex items-center gap-2 px-3 pb-2 text-xs text-base-content/50">
- <!-- Status icon -->
- <span class="shrink-0">
- {#if agentStatus === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
- {:else if agentStatus === "error"}
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-error" aria-label="Error">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- {:else}
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-success" aria-label="Idle">
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {/if}
- </span>
-
- <!-- Context-window fill bar -->
- {#if usage.percent !== null}
- <progress
- class="progress flex-1 h-2 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <!-- Model's max context is unknown → inert, disabled bar. -->
- <progress class="progress flex-1 h-2 opacity-40" value="0" max="100"></progress>
- {/if}
-
- <!-- Context size + percent -->
- <span class="shrink-0 font-mono whitespace-nowrap">
- {#if hasUsage}
- {fmtCompact(usage.current)}{#if usage.max !== null}<span class="text-base-content/40"> / {fmtCompact(usage.max)}</span>{/if}
- {#if usage.percent !== null}
- <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
- {/if}
- {:else}
- <span class="text-base-content/40">— tokens</span>
- {/if}
- </span>
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
deleted file mode 100644
index 54e99c8..0000000
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ /dev/null
@@ -1,148 +0,0 @@
-<script lang="ts">
-import { appSettings } from "../settings.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-import type { ChatMessage, Chunk, SystemChunkKind } from "../types.js";
-import MarkdownRenderer from "./MarkdownRenderer.svelte";
-import ToolCallDisplay from "./ToolCallDisplay.svelte";
-
-const { message, tabId }: { message: ChatMessage; tabId?: string } = $props();
-
-const isUser = $derived(message.role === "user");
-const isSystem = $derived(message.role === "system");
-
-// Check if this message is queued: its id starts with "queued-"
-const queuedMessageId = $derived(
- isUser && message.id.startsWith("queued-") ? message.id.slice("queued-".length) : null,
-);
-const isQueued = $derived(queuedMessageId !== null);
-
-function cancelQueued() {
- if (tabId && queuedMessageId) {
- void tabStore.cancelQueuedMessage(tabId, queuedMessageId);
- }
-}
-
-function chunkKey(chunk: Chunk, i: number): string {
- if (chunk.type === "tool-batch") {
- // Stable-ish: first call id + count keeps re-renders sane while streaming.
- return `tb-${chunk.calls[0]?.id ?? i}-${chunk.calls.length}`;
- }
- return `${chunk.type}-${i}`;
-}
-
-const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
- notice: "Notice",
- "model-changed": "Model changed",
- "config-reload": "Config reload",
- cancelled: "Cancelled",
-};
-
-/**
- * Returns true if the given chunk has visible content worth rendering.
- * Used by `hasRenderableContent` to suppress empty assistant bubbles.
- *
- * Note: `ThinkingChunk.metadata` is intentionally excluded — it is
- * internal wire data (Anthropic's providerMetadata / signature) and
- * must never appear in the UI.
- */
-function chunkHasRenderableContent(chunk: Chunk): boolean {
- switch (chunk.type) {
- case "text":
- return chunk.text.length > 0;
- case "thinking":
- return chunk.text.length > 0;
- case "tool-batch":
- return chunk.calls.length > 0;
- case "error":
- return true;
- case "system":
- return true;
- }
-}
-
-/**
- * True when the assistant bubble has something worth showing.
- * Guards the assistant render path so we don't emit an empty box
- * (e.g. a message that only had empty/signature-only thinking blocks
- * from Anthropic adaptive thinking mode).
- *
- * Streaming messages always have renderable content — the cursor
- * needs somewhere to live.
- */
-const hasRenderableContent = $derived(
- message.isStreaming === true || message.chunks.some(chunkHasRenderableContent),
-);
-</script>
-
-{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)}
- {#each chunks as chunk, i (chunkKey(chunk, i))}
- {#if chunk.type === "text"}
- <MarkdownRenderer text={chunk.text} {streaming} />
- {:else if chunk.type === "thinking"}
- <!-- Skip empty thinking chunks: Anthropic adaptive thinking can emit
- a reasoning-end with a signature but no thinking_delta content.
- The metadata is internal wire data — never displayed. -->
- {#if chunk.text.length > 0}
- <div class="collapse collapse-arrow mb-2 p-1">
- <input type="checkbox" checked={appSettings.autoExpandThinking} />
- <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div>
- <div class="collapse-content text-sm opacity-60 italic p-0">
- <p class="whitespace-pre-wrap mt-1">{chunk.text}</p>
- </div>
- </div>
- {/if}
- {:else if chunk.type === "tool-batch"}
- {#each chunk.calls as call (call.id)}
- <ToolCallDisplay toolCall={call} />
- {/each}
- {:else if chunk.type === "error"}
- <div class="alert alert-error my-2 py-2 px-3 text-sm rounded border border-error/60 bg-error/10 text-error">
- <div class="flex flex-col gap-0.5 w-full">
- <span class="break-words">{chunk.message}</span>
- {#if chunk.statusCode !== undefined}
- <span class="text-xs opacity-70">status {chunk.statusCode}</span>
- {/if}
- </div>
- </div>
- {:else if chunk.type === "system"}
- <div class="my-1 text-xs italic opacity-50 flex gap-1 items-baseline">
- <span class="font-semibold not-italic">{SYSTEM_KIND_LABEL[chunk.kind]}:</span>
- <span class="break-words">{chunk.text}</span>
- </div>
- {/if}
- {/each}
-{/snippet}
-
-{#if isSystem}
- <div class="flex justify-center my-2 px-4">
- <div class="max-w-full text-center">
- {@render renderChunks(message.chunks, false)}
- </div>
- </div>
-{:else if !isUser && !hasRenderableContent}
- <!-- Empty assistant message — no renderable chunks and not streaming.
- Suppressed to avoid an empty bubble (e.g. a turn that produced
- only empty/signature-only thinking blocks from Anthropic adaptive
- thinking mode, or a done event with no content). -->
-{:else}
-<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}">
- <div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
- {@render renderChunks(message.chunks, message.isStreaming)}
- {#if message.isStreaming}
- <span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle rounded-sm"></span>
- {/if}
- </div>
- {#if isQueued}
- <div class="flex items-center gap-1 mt-0.5 ml-1">
- <span class="badge badge-ghost badge-xs text-base-content/40">queued</span>
- <button
- class="btn btn-xs btn-ghost text-base-content/40 hover:text-error px-1 min-h-0 h-auto"
- onclick={cancelQueued}
- title="Cancel queued message"
- >
- ✕
- </button>
- </div>
- {/if}
-</div>
-{/if}
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
deleted file mode 100644
index ee1b8ef..0000000
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ /dev/null
@@ -1,181 +0,0 @@
-<script lang="ts">
-import { tick, untrack } from "svelte";
-import { tabStore } from "../tabs.svelte.js";
-import ChatMessageComponent from "./ChatMessage.svelte";
-
-let messagesEl: HTMLDivElement | undefined;
-let userScrolledUp = $state(false);
-let isAutoScrolling = false;
-let isLoadingMore = $state(false);
-
-const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []);
-const activeTabId = $derived(tabStore.activeTab?.id);
-// Compaction placeholder state for the active tab. `compactingSource` is set on
-// a transient placeholder tab while a conversation is being compacted;
-// `compactionError` is set if it failed.
-const compactingSource = $derived(tabStore.activeTab?.compactingSource ?? null);
-const compactionError = $derived(tabStore.activeTab?.compactionError ?? null);
-
-// Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}`
-// (n = its index among same-(turn,role) messages) rather than the underlying
-// row/client id, so when the live turn reconciles into sealed chunk rows the
-// bubble keeps its identity and does NOT remount (no flash). Falls back to the
-// message id for anything without a turnId (e.g. optimistic/queued messages
-// before turn-start, standalone system notices).
-const keyedMessages = $derived.by(() => {
- const counts = new Map<string, number>();
- return renderGroups.map((m) => {
- if (!m.turnId) return { m, key: m.id };
- const base = `${m.turnId}:${m.role}`;
- const n = counts.get(base) ?? 0;
- counts.set(base, n + 1);
- return { m, key: `${base}:${n}` };
- });
-});
-
-function isNearBottom(el: HTMLElement): boolean {
- return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
-}
-
-function scrollToBottom(animate = false) {
- if (!messagesEl) return;
- messagesEl.scrollTo({
- top: messagesEl.scrollHeight,
- behavior: animate ? "smooth" : "instant",
- });
-}
-
-async function onNearTop() {
- if (isLoadingMore) return;
- const tab = tabStore.activeTab;
- if (!tab) return;
- // Nothing older to load if we're already at the very first message, or if
- // we already hold every message the backend has for this tab.
- if (tab.oldestLoadedSeq !== null && tab.oldestLoadedSeq <= 0) return;
-
- isLoadingMore = true;
- const prevScrollHeight = messagesEl?.scrollHeight ?? 0;
- const prevScrollTop = messagesEl?.scrollTop ?? 0;
- try {
- await tabStore.loadOlderChunks(tab.id);
- // Wait for Svelte to flush the prepended messages into the DOM.
- // Reading `scrollHeight` synchronously after the await would observe
- // the OLD layout (reactive updates are batched), so the scroll
- // correction would be computed against a stale height and the
- // viewport would jump. `tick()` resolves once the DOM reflects the
- // new message list.
- await tick();
- if (messagesEl) {
- const newScrollHeight = messagesEl.scrollHeight;
- const delta = newScrollHeight - prevScrollHeight;
- // Only adjust when content was actually prepended above the
- // viewport. If nothing was added (all duplicates / nothing older),
- // `delta` is 0 and we leave the user where they are instead of
- // snapping to the top.
- if (delta > 0) {
- messagesEl.scrollTop = prevScrollTop + delta;
- }
- }
- } finally {
- isLoadingMore = false;
- }
-}
-
-function handleScroll() {
- if (!messagesEl || isAutoScrolling) return;
- const wasScrolledUp = userScrolledUp;
- userScrolledUp = !isNearBottom(messagesEl);
- if (activeTabId) tabStore.setScrolledUp(activeTabId, userScrolledUp);
- // User just scrolled back to the bottom manually — safe to evict now.
- if (wasScrolledUp && !userScrolledUp && activeTabId) {
- tabStore.evictChunks(activeTabId);
- }
- // Near the top — pull in older history.
- if (userScrolledUp && messagesEl.scrollTop < 200) {
- void onNearTop();
- }
-}
-
-function resumeAutoScroll() {
- userScrolledUp = false;
- isAutoScrolling = true;
- if (activeTabId) {
- tabStore.setScrolledUp(activeTabId, false);
- tabStore.evictChunks(activeTabId);
- }
- scrollToBottom(true);
-}
-
-$effect(() => {
- const count = renderGroups.length;
- void count;
- if (messagesEl) {
- untrack(() => {
- if (!userScrolledUp) scrollToBottom(false);
- });
- }
-});
-
-$effect(() => {
- const prevTabId = activeTabId;
- // Reset scroll state when switching tabs
- userScrolledUp = false;
- isAutoScrolling = false;
- return () => {
- if (prevTabId) {
- tabStore.setScrolledUp(prevTabId, false);
- }
- };
-});
-</script>
-
-<div class="flex flex-col h-full">
- <!-- Messages -->
- <div class="relative flex-1 min-h-0">
- <div
- bind:this={messagesEl}
- class="h-full overflow-y-auto p-4"
- onscroll={handleScroll}
- onscrollend={() => {
- isAutoScrolling = false;
- }}
- >
- {#if isLoadingMore}
- <div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div>
- {/if}
- {#if compactingSource || compactionError}
- <div class="flex flex-col items-center justify-center h-full gap-4 px-6 text-center">
- {#if compactionError}
- <div class="text-2xl font-semibold text-error">Compaction failed</div>
- <div class="text-base text-base-content/70 max-w-md">{compactionError}</div>
- <div class="text-sm text-base-content/50">Close this tab to dismiss — your conversation was not changed.</div>
- {:else}
- <span class="loading loading-spinner loading-lg text-primary"></span>
- <div class="text-2xl font-semibold text-base-content">Please wait, compacting conversation…</div>
- <div class="text-sm text-base-content/50">You can cancel by closing this tab.</div>
- {/if}
- </div>
- {:else if renderGroups.length === 0}
- <div class="flex items-center justify-center h-full text-base-content/40 text-sm">
- Send a message to start a conversation
- </div>
- {/if}
- {#if !compactingSource && !compactionError}
- {#each keyedMessages as { m, key } (key)}
- <ChatMessageComponent message={m} tabId={activeTabId} />
- {/each}
- {/if}
- </div>
-
- <!-- Scroll-to-bottom button -->
- <button
- type="button"
- class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-sm px-8 rounded-lg shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
- onclick={resumeAutoScroll}
- aria-label="Scroll to bottom"
- tabindex={userScrolledUp ? 0 : -1}
- >
- &#x25BC;
- </button>
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
deleted file mode 100644
index eea8744..0000000
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ /dev/null
@@ -1,375 +0,0 @@
-<script lang="ts">
-import { onDestroy } from "svelte";
-import { SnapshotSequencer } from "../snapshot-sequencer.js";
-
-const { apiBase = "" }: { apiBase?: string } = $props();
-
-/** Fixed offset (hours) from a wake to the "Claude session reset" display.
- * Mirrors the backend constant — kept in sync via the GET response. */
-const DEFAULT_RESET_OFFSET_HOURS = 5;
-const DEFAULT_PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
-
-type ProbeMinute = number; // 0 | 15 | 30 | 45 in practice
-type HourSlots = Record<ProbeMinute, number>; // slot minute → next fire ms
-
-interface WakeResult {
- label: string;
- ok: boolean;
- error?: string;
-}
-
-interface LastWake {
- firedAt: number;
- ok: boolean;
- results: WakeResult[];
-}
-
-interface PendingRetry {
- retriesLeft: number;
- nextRetryAt: number;
- reason: string;
-}
-
-interface ScheduleSnapshot {
- /** hour (0-23) → { slotMinute → next fire ms }. */
- schedule: Record<string, Record<string, number>>;
- resetOffsetHours?: number;
- probeSlotMinutes?: number[];
- lastWake?: LastWake | null;
- pendingRetry?: PendingRetry | null;
-}
-
-// Marked hours: hour → { slotMinute → next fire ms }. Empty inner record = not marked.
-let schedule = $state<Record<number, HourSlots>>({});
-let resetOffsetHours = $state<number>(DEFAULT_RESET_OFFSET_HOURS);
-let probeSlotMinutes = $state<readonly number[]>(DEFAULT_PROBE_SLOT_MINUTES);
-let lastWake = $state<LastWake | null>(null);
-let pendingRetry = $state<PendingRetry | null>(null);
-
-/**
- * Global mutation lock: the hour whose toggle POST is currently in flight,
- * or null if none. Disables ALL toggle buttons (not just this hour's) while
- * any mutation is pending.
- *
- * Why global, not per-hour: snapshot responses can be reordered on the wire,
- * but worse, requests themselves can be reordered. If two POSTs are in
- * flight and the SERVER processes them out of order, the snapshot the
- * SnapshotSequencer picks as "winner" (highest client-send seq) may not be
- * the snapshot reflecting the truest server state — the UI desyncs from
- * the server permanently. Serializing mutations on the client eliminates
- * the reorder window entirely. (The sequencer is still useful for the
- * GET-on-mount vs first-click race.)
- */
-let pendingHour = $state<number | null>(null);
-
-/**
- * Single global sequencer for ALL /models/wake-schedule responses (initial
- * GET + every toggle POST). Each response is dropped if a newer request has
- * already won. This protects against three races:
- * 1. Two toggles on different hours land out of order — older snapshot
- * blindly overwrites the newer one, and the most-recent click vanishes
- * from the UI.
- * 2. The initial loadFromServer is still in flight when the user clicks.
- * 3. Any future fan-out (e.g. polling) racing a user action.
- * A per-hour counter was insufficient because applySnapshot replaces the
- * whole `schedule` object, not just one hour's slot. See snapshot-sequencer.ts.
- */
-const sequencer = new SnapshotSequencer();
-
-/** Live "now" used for the current-hour ring + relative timestamps. */
-let nowMs = $state<number>(Date.now());
-
-const nowTimer = setInterval(() => {
- nowMs = Date.now();
-}, 30_000);
-
-onDestroy(() => {
- clearInterval(nowTimer);
-});
-
-function formatHour(h: number): string {
- const display = h % 12;
- return display === 0 ? "12" : String(display);
-}
-
-/**
- * Compute the next occurrence of HH:MM in the user's local timezone.
- * Today if still future, else tomorrow.
- */
-function nextOccurrenceAt(hour: number, minute: number): number {
- const target = new Date();
- target.setHours(hour, minute, 0, 0);
- if (target.getTime() <= Date.now()) {
- target.setDate(target.getDate() + 1);
- }
- return target.getTime();
-}
-
-function applySnapshot(data: ScheduleSnapshot): void {
- const parsed: Record<number, HourSlots> = {};
- for (const [hourStr, slots] of Object.entries(data.schedule ?? {})) {
- const inner: HourSlots = {};
- for (const [slotStr, ts] of Object.entries(slots ?? {})) {
- inner[Number(slotStr)] = ts;
- }
- parsed[Number(hourStr)] = inner;
- }
- schedule = parsed;
- if (typeof data.resetOffsetHours === "number") {
- resetOffsetHours = data.resetOffsetHours;
- }
- if (Array.isArray(data.probeSlotMinutes) && data.probeSlotMinutes.length > 0) {
- probeSlotMinutes = data.probeSlotMinutes;
- }
- lastWake = data.lastWake ?? null;
- pendingRetry = data.pendingRetry ?? null;
-}
-
-async function loadFromServer(): Promise<void> {
- const mySeq = sequencer.begin();
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule`);
- if (!res.ok) return;
- const data = (await res.json()) as ScheduleSnapshot;
- if (!sequencer.accept(mySeq)) return; // a newer response already won
- applySnapshot(data);
- } catch {
- // Network error — leave existing state
- }
-}
-
-function setPending(hour: number | null): void {
- pendingHour = hour;
-}
-
-async function postToggle(
- hour: number,
- action: "on" | "off",
- timestamps?: Record<number, number>,
-): Promise<void> {
- const mySeq = sequencer.begin();
- setPending(hour);
-
- try {
- const body: {
- hour: number;
- action: "on" | "off";
- timestamps?: Record<string, number>;
- } = { hour, action };
- if (timestamps) {
- const stringKeyed: Record<string, number> = {};
- for (const [k, v] of Object.entries(timestamps)) stringKeyed[k] = v;
- body.timestamps = stringKeyed;
- }
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) return;
- const data = (await res.json()) as ScheduleSnapshot;
- // Drop stale snapshots — only the most-recent request wins for ALL
- // shared state (schedule, lastWake, pendingRetry). Even with the
- // global mutation lock, this still catches the GET-on-mount vs
- // first-click race.
- if (!sequencer.accept(mySeq)) return;
- applySnapshot(data);
- } catch {
- // Network error — leave local state alone; user can re-toggle.
- } finally {
- setPending(null);
- }
-}
-
-function toggleHour(hour: number): void {
- // Global lock: any pending mutation on ANY hour blocks new clicks. This
- // serializes POSTs on the wire so the server never has to choose between
- // two concurrent requests — eliminating the request-reorder failure mode
- // where the sequencer's "highest client seq wins" rule would discard the
- // snapshot reflecting the true server state.
- if (pendingHour !== null) return;
- if (schedule[hour] !== undefined) {
- // User intent: turn this hour OFF.
- void postToggle(hour, "off");
- } else {
- // User intent: turn this hour ON — compute first occurrence of HH:MM
- // for each probe slot, in the user's local timezone.
- const timestamps: Record<number, number> = {};
- for (const minute of probeSlotMinutes) {
- timestamps[minute] = nextOccurrenceAt(hour, minute);
- }
- void postToggle(hour, "on", timestamps);
- }
-}
-
-$effect(() => {
- void loadFromServer();
-});
-
-/**
- * Faded hours: the `resetOffsetHours - 1` hours immediately after each
- * marked hour (the "active session window"). Excludes hours that are
- * themselves marked.
- */
-const fadedHours = $derived.by((): Set<number> => {
- const result = new Set<number>();
- const window = Math.max(0, resetOffsetHours - 1);
- for (const h of Object.keys(schedule).map(Number)) {
- for (let i = 1; i <= window; i++) {
- const faded = (h + i) % 24;
- if (schedule[faded] === undefined) {
- result.add(faded);
- }
- }
- }
- return result;
-});
-
-const currentHour = $derived(new Date(nowMs).getHours());
-
-function blockClass(hour: number, faded: Set<number>): string {
- const isMarked = schedule[hour] !== undefined;
- const isCurrent = hour === currentHour;
- const isFaded = faded.has(hour);
- // Only the hour whose request is in flight shows the "wait" cursor;
- // other buttons are merely disabled (via the template `disabled={...}`).
- const isPending = pendingHour === hour;
-
- let base =
- "flex items-center justify-center rounded select-none text-[10px] font-mono transition-colors";
-
- if (isPending) {
- base += " opacity-60 cursor-wait";
- } else {
- base += " cursor-pointer";
- }
-
- if (isMarked) {
- base += " bg-primary text-primary-content";
- } else if (isFaded) {
- base += " bg-primary/25 text-base-content";
- } else {
- base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
- }
-
- if (isCurrent) {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- }
-
- return base;
-}
-
-function formatAmPm(hour24: number): string {
- const h = hour24 % 12;
- const ampm = hour24 < 12 ? "AM" : "PM";
- return `${h === 0 ? "12" : String(h)}:00 ${ampm}`;
-}
-
-function resetHour(wakeHour: number): number {
- return (wakeHour + resetOffsetHours) % 24;
-}
-
-function formatRelative(ts: number, now: number): string {
- const diff = now - ts;
- if (diff < 0) {
- const ahead = -diff;
- if (ahead < 60_000) return "in <1 min";
- if (ahead < 3600_000) return `in ${Math.round(ahead / 60_000)} min`;
- return `in ${Math.round(ahead / 3600_000)} h`;
- }
- if (diff < 60_000) return "just now";
- if (diff < 3600_000) return `${Math.round(diff / 60_000)} min ago`;
- if (diff < 86_400_000) return `${Math.round(diff / 3600_000)} h ago`;
- return `${Math.round(diff / 86_400_000)} d ago`;
-}
-
-const markedHours = $derived(
- Object.keys(schedule)
- .map(Number)
- .sort((a, b) => a - b),
-);
-
-function probeLabel(minute: number): string {
- return `:${String(minute).padStart(2, "0")}`;
-}
-
-const probeLabels = $derived(probeSlotMinutes.map(probeLabel).join(" "));
-
-const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
-const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
-const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
-const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
-</script>
-
-<div class="flex flex-col gap-2">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Claude Wake Schedule</div>
-
- <!-- AM rows -->
- <div class="flex items-center gap-1">
- <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">AM</span>
- <div class="flex flex-col gap-0.5">
- <div class="flex gap-0.5">
- {#each amRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- <div class="flex gap-0.5">
- {#each amRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- </div>
- </div>
-
- <!-- PM rows -->
- <div class="flex items-center gap-1">
- <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">PM</span>
- <div class="flex flex-col gap-0.5">
- <div class="flex gap-0.5">
- {#each pmRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- <div class="flex gap-0.5">
- {#each pmRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- </div>
- </div>
-
- <!-- Marked hours summary -->
- {#if markedHours.length > 0}
- <div class="flex flex-col gap-0.5 mt-1">
- {#each markedHours as hour}
- <div class="flex items-center gap-1.5 text-xs text-base-content/70">
- <span class="badge badge-xs badge-primary whitespace-nowrap shrink-0">{formatHour(hour)} {hour < 12 ? "AM" : "PM"}</span>
- <span>Probes {probeLabels} → reset by {formatAmPm(resetHour(hour))}</span>
- </div>
- {/each}
- </div>
- {:else}
- <p class="text-xs text-base-content/40 italic">No wake hours marked. Click a block to probe at {probeLabels} that hour.</p>
- {/if}
-
- <!-- Status: last wake / pending retry -->
- {#if lastWake}
- <div class="flex items-center gap-1.5 text-xs mt-1" class:text-success={lastWake.ok} class:text-error={!lastWake.ok}>
- <span class="font-semibold">{lastWake.ok ? "✓" : "✗"}</span>
- <span>Last wake {formatRelative(lastWake.firedAt, nowMs)}{lastWake.ok ? "" : ` — ${lastWake.results.find((r) => !r.ok)?.error ?? "failed"}`}</span>
- </div>
- {/if}
- {#if pendingRetry}
- <div class="text-xs text-warning">
- Retrying ({pendingRetry.retriesLeft} left, next {formatRelative(pendingRetry.nextRetryAt, nowMs)})
- </div>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte
deleted file mode 100644
index f517d28..0000000
--- a/packages/frontend/src/lib/components/ConfigPanel.svelte
+++ /dev/null
@@ -1,251 +0,0 @@
-<script lang="ts">
-interface AgentTemplate {
- name: string;
- description?: string;
- model_tag?: string;
- tools?: string[];
-}
-
-interface ModelEntry {
- id: string;
- provider?: string;
- tags?: string[];
-}
-
-interface KeyEntry {
- id: string;
- provider?: string;
- status?: string;
- lastError?: string;
- exhaustedAt?: string;
-}
-
-interface ConfigData {
- agents?: Record<string, AgentTemplate>;
- models?: Record<string, { provider?: string; tags?: string[] }>;
- keys?: Record<string, { env?: string }>;
- fallback?: string[];
- permissions?: Record<string, unknown>;
-}
-
-interface ModelsData {
- models?: ModelEntry[];
- tags?: Record<string, string[]>;
- keys?: KeyEntry[];
-}
-
-const { apiBase }: { apiBase: string } = $props();
-
-let configData = $state<ConfigData | null>(null);
-let modelsData = $state<ModelsData | null>(null);
-let error = $state<string | null>(null);
-let loading = $state(false);
-
-async function fetchData() {
- loading = true;
- error = null;
- try {
- const [configRes, modelsRes] = await Promise.all([
- fetch(`${apiBase}/config`),
- fetch(`${apiBase}/models`),
- ]);
- if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
- if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
- const configJson = await configRes.json();
- const modelsJson = await modelsRes.json();
- configData = configJson.config ?? configJson;
- modelsData = modelsJson;
- } catch (e) {
- error = e instanceof Error ? e.message : String(e);
- } finally {
- loading = false;
- }
-}
-
-$effect(() => {
- fetchData();
-});
-
-const modelCount = $derived(modelsData?.models?.length ?? 0);
-const keyCount = $derived(modelsData?.keys?.length ?? 0);
-
-function formatDate(iso: string | undefined): string {
- if (!iso) return "";
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
-}
-
-function permissionEntries(
- permissions: Record<string, unknown>,
-): Array<{ name: string; value: unknown }> {
- return Object.entries(permissions).map(([name, value]) => ({ name, value }));
-}
-
-function isSimpleRule(value: unknown): value is { action: string } {
- return (
- typeof value === "object" &&
- value !== null &&
- "action" in value &&
- Object.keys(value).length === 1
- );
-}
-
-function isPatternRule(value: unknown): value is Record<string, { action: string }> {
- return typeof value === "object" && value !== null && !("action" in value);
-}
-</script>
-
-<details class="collapse collapse-arrow bg-base-200 mt-2">
- <summary class="collapse-title text-sm font-medium flex items-center gap-2">
- <span>Configuration</span>
- {#if modelCount > 0 || keyCount > 0}
- <span class="badge badge-sm badge-neutral">{modelCount} models</span>
- <span class="badge badge-sm badge-neutral">{keyCount} keys</span>
- {/if}
- {#if loading}
- <span class="loading loading-spinner loading-xs ml-auto"></span>
- {/if}
- </summary>
-
- <div class="collapse-content text-xs">
- {#if error}
- <div class="alert alert-error alert-sm py-1 mb-2 text-xs">
- <span>Failed to load config: {error}</span>
- </div>
- {/if}
-
- <div class="flex justify-end mb-2">
- <button
- type="button"
- class="btn btn-xs btn-ghost"
- onclick={fetchData}
- disabled={loading}
- >
- {loading ? "Loading…" : "Refresh"}
- </button>
- </div>
-
- <!-- Agent Templates -->
- {#if configData?.agents && Object.keys(configData.agents).length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Agent Templates</div>
- {#each Object.entries(configData.agents) as [name, template]}
- <div class="bg-base-100 rounded p-2 mb-1">
- <div class="flex items-center gap-2 flex-wrap">
- <span class="font-medium">{name}</span>
- {#if template.model_tag}
- <span class="badge badge-xs badge-info">{template.model_tag}</span>
- {/if}
- </div>
- {#if template.description}
- <p class="text-base-content/60 mt-0.5">{template.description}</p>
- {/if}
- {#if template.tools && template.tools.length > 0}
- <div class="flex flex-wrap gap-1 mt-1">
- {#each template.tools as tool}
- <span class="badge badge-xs badge-ghost">{tool}</span>
- {/each}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Models -->
- {#if modelsData?.models && modelsData.models.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Models</div>
- {#each modelsData.models as model}
- <div class="flex items-center gap-2 flex-wrap py-0.5 border-b border-base-300">
- <span class="font-mono">{model.id}</span>
- {#if model.provider}
- <span class="badge badge-xs badge-primary">{model.provider}</span>
- {/if}
- {#if model.tags && model.tags.length > 0}
- {#each model.tags as tag}
- <span class="badge badge-xs badge-ghost">{tag}</span>
- {/each}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Keys -->
- {#if modelsData?.keys && modelsData.keys.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">API Keys</div>
- {#each modelsData.keys as key}
- <div class="bg-base-100 rounded p-1.5 mb-1">
- <div class="flex items-center gap-2 flex-wrap">
- <span class="font-mono">{key.id}</span>
- {#if key.provider}
- <span class="badge badge-xs badge-secondary">{key.provider}</span>
- {/if}
- {#if key.status === "exhausted"}
- <span class="badge badge-xs badge-error">exhausted</span>
- {:else}
- <span class="badge badge-xs badge-success">active</span>
- {/if}
- </div>
- {#if key.status === "exhausted" && key.lastError}
- <p class="text-error/80 mt-0.5 truncate">{key.lastError}</p>
- {/if}
- {#if key.exhaustedAt}
- <p class="text-base-content/40 mt-0.5">Since {formatDate(key.exhaustedAt)}</p>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Fallback Order -->
- {#if configData?.fallback && configData.fallback.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Fallback Order</div>
- <ol class="list-none space-y-0.5">
- {#each configData.fallback as keyId, i}
- <li class="flex items-center gap-2">
- <span class="badge badge-xs badge-outline">{i + 1}</span>
- <span class="font-mono">{keyId}</span>
- </li>
- {/each}
- </ol>
- </div>
- {/if}
-
- <!-- Permissions -->
- {#if configData?.permissions && Object.keys(configData.permissions).length > 0}
- <div class="mb-1">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Permissions</div>
- {#each permissionEntries(configData.permissions) as entry}
- <div class="py-0.5 border-b border-base-300">
- <span class="font-medium text-base-content/80">{entry.name}</span>
- {#if isSimpleRule(entry.value)}
- <span class="ml-2 badge badge-xs {entry.value.action === 'allow' ? 'badge-success' : 'badge-error'}">{entry.value.action}</span>
- {:else if isPatternRule(entry.value)}
- {#each Object.entries(entry.value) as [pattern, rule]}
- <div class="pl-2 flex items-center gap-2">
- <span class="text-base-content/50 font-mono truncate max-w-32">{pattern}</span>
- {#if typeof rule === "object" && rule !== null && "action" in rule}
- <span class="badge badge-xs {(rule as { action: string }).action === 'allow' ? 'badge-success' : 'badge-error'}">{(rule as { action: string }).action}</span>
- {/if}
- </div>
- {/each}
- {:else}
- <span class="text-base-content/40 ml-2">{JSON.stringify(entry.value)}</span>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- {#if !loading && !error && !configData && !modelsData}
- <p class="text-base-content/40 italic">No configuration loaded.</p>
- {/if}
- </div>
-</details>
diff --git a/packages/frontend/src/lib/components/ContextWindowPanel.svelte b/packages/frontend/src/lib/components/ContextWindowPanel.svelte
deleted file mode 100644
index 6c7de05..0000000
--- a/packages/frontend/src/lib/components/ContextWindowPanel.svelte
+++ /dev/null
@@ -1,85 +0,0 @@
-<script lang="ts">
-import { computeContextUsage } from "../context-window.js";
-import type { CacheStats } from "../types.js";
-
-const {
- cacheStats = null,
- contextLimit = null,
- tabTitle = null,
- modelId = null,
-}: {
- cacheStats?: CacheStats | null;
- contextLimit?: number | null;
- tabTitle?: string | null;
- modelId?: string | null;
-} = $props();
-
-const usage = $derived(computeContextUsage(cacheStats, contextLimit));
-
-// As the window fills, escalate color: calm → warning → danger.
-function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
-}
-
-function fmt(n: number): string {
- return n.toLocaleString();
-}
-
-const hasUsage = $derived((cacheStats?.last ?? null) !== null);
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- {#if !hasUsage}
- <p class="text-xs text-base-content/50">
- No context data yet. Send a message — the current context size appears
- here after the first response.
- </p>
- {:else}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-2">
- <span class="text-xs font-semibold">Context Window</span>
- {#if tabTitle}
- <span class="badge badge-xs badge-ghost">{tabTitle}</span>
- {/if}
- {#if usage.percent !== null}
- <span class="badge badge-xs ml-auto">{usage.percent.toFixed(2)}%</span>
- {/if}
- </div>
-
- <!-- Headline: current / max (or just current when max is unknown) -->
- <div class="flex items-baseline gap-1.5">
- <span class="text-lg font-mono font-semibold">{fmt(usage.current)}</span>
- {#if usage.max !== null}
- <span class="text-xs text-base-content/50 font-mono">/ {fmt(usage.max)}</span>
- {/if}
- <span class="text-xs text-base-content/40 ml-1">tokens</span>
- </div>
-
- {#if usage.percent !== null}
- <progress
- class="progress w-full h-2 mt-1.5 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <p class="text-xs text-base-content/40 mt-1.5">
- Max context size unknown for this model.
- </p>
- {/if}
-
- {#if modelId}
- <div class="text-xs text-base-content/40 mt-1.5 truncate" title={modelId}>
- {modelId}
- </div>
- {/if}
- </div>
-
- <p class="text-xs text-base-content/40">
- Current context = the most recent request's prompt + output (what the
- model actually held in its window that turn). Grows as the conversation
- gets longer. Resets on reload.
- </p>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/DebugPanel.svelte b/packages/frontend/src/lib/components/DebugPanel.svelte
deleted file mode 100644
index aea1ccb..0000000
--- a/packages/frontend/src/lib/components/DebugPanel.svelte
+++ /dev/null
@@ -1,35 +0,0 @@
-<script lang="ts">
-import { tabStore } from "../tabs.svelte.js";
-
-let copyLabel = $state("Copy conversation");
-
-function resetCopyLabel(): void {
- copyLabel = "Copy conversation";
-}
-
-async function handleCopy(): Promise<void> {
- const text = tabStore.copyConversation();
- try {
- await navigator.clipboard.writeText(text);
- copyLabel = "Copied";
- } catch {
- copyLabel = "Failed";
- }
- setTimeout(resetCopyLabel, 1500);
-}
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Debug</div>
-
- <div class="flex flex-col gap-2">
- <p class="text-xs text-base-content/70">Conversation</p>
- <p class="text-xs text-base-content/40">
- Copy a structured plain-text dump of the active tab's conversation
- (chunk shape included) for bug reports.
- </p>
- <button type="button" class="btn btn-sm btn-primary w-full" onclick={handleCopy}>
- {copyLabel}
- </button>
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
deleted file mode 100644
index 72a6ebf..0000000
--- a/packages/frontend/src/lib/components/Header.svelte
+++ /dev/null
@@ -1,27 +0,0 @@
-<script lang="ts">
-import ListIcon from "phosphor-svelte/lib/ListIcon";
-import { router } from "../router.svelte.js";
-import { wsClient } from "../ws.svelte.js";
-
-const { onToggleSidebar }: { onToggleSidebar: () => void } = $props();
-</script>
-
-<header class="navbar bg-base-200 px-4 min-h-14 flex-shrink-0">
- <div class="navbar-start">
- <button class="text-xl font-bold tracking-tight btn btn-ghost px-0" onclick={() => router.navigate("dashboard")}>Dispatch</button>
- </div>
- <div class="navbar-end flex items-center gap-3">
- <span class="flex items-center gap-1.5 text-xs text-base-content/60">
- <span class="status status-sm {wsClient.connectionStatus === 'connected' ? 'status-success' : wsClient.connectionStatus === 'connecting' ? 'status-warning' : 'status-error'}"></span>
- <span class="capitalize">{wsClient.connectionStatus}</span>
- </span>
- <button
- type="button"
- class="btn btn-square btn-sm btn-neutral"
- onclick={onToggleSidebar}
- aria-label="Toggle sidebar"
- >
- <ListIcon size={20} weight="bold" aria-hidden="true" />
- </button>
- </div>
-</header>
diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
deleted file mode 100644
index 8d37578..0000000
--- a/packages/frontend/src/lib/components/HotReloadIndicator.svelte
+++ /dev/null
@@ -1,35 +0,0 @@
-<script lang="ts">
-const { active }: { active: boolean } = $props();
-
-let visible = $state(false);
-let timer: ReturnType<typeof setTimeout> | null = null;
-
-$effect(() => {
- if (active) {
- visible = true;
- if (timer !== null) clearTimeout(timer);
- timer = setTimeout(() => {
- visible = false;
- timer = null;
- }, 2000);
- }
- return () => {
- if (timer !== null) {
- clearTimeout(timer);
- timer = null;
- }
- visible = false;
- };
-});
-</script>
-
-{#if visible}
- <div
- class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-info/20 text-info text-xs font-medium animate-pulse"
- role="status"
- aria-live="polite"
- >
- <span class="status status-xs status-info"></span>
- <span>Config reloaded</span>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
deleted file mode 100644
index 7c0cadc..0000000
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ /dev/null
@@ -1,477 +0,0 @@
-<script lang="ts">
-import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
-}
-
-// In-memory cache for the current session (backend DB is the persistent cache)
-const usageCache = new Map<string, KeyUsageData>();
-
-function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
- return keyList.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true, // always show spinner during refresh
- };
- });
-}
-
-let entries = $state<KeyUsageEntry[]>([]);
-
-async function fetchOne(key: KeyInfo) {
- try {
- const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`);
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- updateEntry(key.id, {
- error: data.error ?? `HTTP ${res.status}`,
- loading: false,
- });
- } else {
- const fresh = (await res.json()) as KeyUsageData;
- usageCache.set(key.id, fresh);
- updateEntry(key.id, {
- data: fresh,
- error: null,
- loading: false,
- });
- }
- } catch (e) {
- updateEntry(key.id, {
- error: e instanceof Error ? e.message : "Failed to fetch",
- loading: false,
- });
- }
-}
-
-function updateEntry(
- keyId: string,
- patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
-) {
- entries = entries.map((e) => (e.keyId === keyId ? { ...e, ...patch } : e));
-}
-
-// Sync entries with keys reactively — runs before DOM update so
-// cached data renders on first paint without a flash of empty state.
-$effect.pre(() => {
- entries = buildEntries(keys);
-});
-
-// Fetch data and set up 90s auto-refresh
-$effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
- for (const key of currentKeys) {
- fetchOne(key);
- }
-
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
-
- return () => clearInterval(interval);
-});
-
-// Merge duplicate Claude entries — all anthropic keys return the same
-// set of accounts, so collect and deduplicate under one "Claude" card.
-const claudeAccounts = $derived.by(() => {
- const seen = new Set<string>();
- const accounts: Array<{
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
- }> = [];
- const claudeEntries = entries.filter((e) => e.provider === "anthropic");
- for (const e of claudeEntries) {
- if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
- for (const acct of e.data.accounts) {
- if (!seen.has(acct.source)) {
- seen.add(acct.source);
- accounts.push(acct);
- }
- }
- }
- return accounts;
-});
-
-const claudeLoading = $derived(entries.some((e) => e.provider === "anthropic" && e.loading));
-const claudeError = $derived(
- entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
-);
-
-const nonClaudeEntries = $derived(entries.filter((e) => e.provider !== "anthropic"));
-
-function progressClass(utilization: number): string {
- if (utilization > 0.8) return "progress-error";
- if (utilization >= 0.5) return "progress-warning";
- return "progress-success";
-}
-
-// Pace-aware coloring for cycle bars that show a "time dot" (elapsed % of the
-// reset window). Red once usage hits 90%, otherwise green when usage is at or
-// behind the dot and orange when it has run ahead of it. Falls back to the
-// plain threshold coloring when no dot is present (elapsedPct < 0).
-function pacedProgressClass(percentUsed: number, elapsedPct: number): string {
- if (percentUsed >= 90) return "progress-error";
- if (elapsedPct < 0) return progressClass(percentUsed / 100);
- if (percentUsed <= elapsedPct) return "progress-success";
- return "progress-warning";
-}
-
-function formatDate(ts: number): string {
- const diff = ts - Date.now();
- const days = Math.floor(diff / 86400000);
- const d = new Date(ts);
- const dateStr =
- d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" }) +
- " " +
- d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
-
- if (diff <= 0) {
- return d.toLocaleString();
- }
- if (diff < 48 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / 3600000);
- const minutes = Math.floor((diff % 3600000) / 60000);
- return `in ${hours}:${String(minutes).padStart(2, "0")}`;
- }
- if (days <= 30) {
- const weeks = Math.floor(days / 7);
- const remDays = days % 7;
- if (weeks > 0 && remDays > 0) {
- return `in ${weeks}w ${remDays}d (${dateStr})`;
- }
- if (weeks > 0) {
- return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
- }
- return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
- }
- return d.toLocaleDateString("en-US", {
- month: "2-digit",
- day: "2-digit",
- year: "numeric",
- });
-}
-
-function formatKeyId(id: string): string {
- if (/^github-copilot/i.test(id)) return "Copilot";
- return id
- .split("-")
- .map((part) => {
- if (part.toLowerCase() === "opencode") return "OpenCode";
- return part.charAt(0).toUpperCase() + part.slice(1);
- })
- .join(" ");
-}
-
-// Cycle durations in ms
-const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
-const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
-const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
-
-function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
- if (!resetsAt) return -1;
- const timeRemaining = resetsAt - Date.now();
- const elapsed = cycleDurationMs - timeRemaining;
- return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
-}
-
-function hasBucketData(bucket: UsageBucket | undefined): boolean {
- return bucket !== undefined && bucket.utilization !== undefined;
-}
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">No keys available.</p>
- {:else}
- <div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- <!-- Claude (all accounts merged under one card) -->
- {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">Claude</span>
- <span class="badge badge-xs badge-ghost">anthropic</span>
- {#if claudeLoading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if claudeAccounts.length === 0 && claudeLoading}
- <div class="flex items-center gap-1.5 py-1">
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else if claudeAccounts.length === 0 && claudeError}
- <div role="alert" class="text-xs text-error/80">{claudeError}</div>
- {:else}
- {#if claudeError}
- <div role="alert" class="text-xs text-error/80 mb-1">{claudeError}</div>
- {/if}
- {#each claudeAccounts as acct, idx (acct.source)}
- {#if idx > 0}
- <div class="border-t border-base-300 my-1.5"></div>
- {/if}
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center gap-1">
- <span class="text-xs font-medium">{acct.label}</span>
- {#if acct.subscriptionType}
- <span class="badge badge-xs">{acct.subscriptionType}</span>
- {/if}
- </div>
- {#if acct.error}
- <p class="text-xs text-error/70">{acct.error}</p>
- {/if}
- {#if hasBucketData(acct.fiveHour)}
- {@const b = acct.fiveHour!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">5-Hour</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(acct.sevenDay)}
- {@const b = acct.sevenDay!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- {/if}
- </div>
- {/if}
-
- <!-- Non-Claude keys -->
- {#each nonClaudeEntries as entry (entry.keyId)}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">{formatKeyId(entry.keyId)}</span>
- <span class="badge badge-xs badge-ghost">{entry.provider}</span>
- {#if entry.loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if entry.loading && !entry.data}
- <div class="flex items-center gap-1.5 py-1">
- <span class="loading loading-spinner loading-xs"></span>
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else}
- {#if entry.error}
- <div role="alert" class="text-xs text-error/80 mb-1">{entry.error}</div>
- {/if}
- {#if !entry.data}
- <p class="text-xs text-base-content/50">No data.</p>
-
- {:else if entry.data.provider === "opencode-go"}
- {#if entry.data.unavailable}
- <p class="text-xs text-base-content/70">Usage data not available. Set OPENCODE_COOKIE env var to enable.</p>
- {#if entry.data.limits}
- <div class="text-xs text-base-content/50 mt-1">
- Limits: {entry.data.limits.fiveHour}/5h &middot; {entry.data.limits.weekly}/wk &middot; {entry.data.limits.monthly}/mo
- </div>
- {/if}
- {#if entry.data.consoleUrl}
- <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1">
- View usage on opencode.ai
- </a>
- {/if}
- {:else}
- {#if hasBucketData(entry.data.fiveHour)}
- {@const b = entry.data.fiveHour!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">5-Hour</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.weekly)}
- {@const b = entry.data.weekly!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.monthly)}
- {@const b = entry.data.monthly!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, THIRTY_DAY_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Monthly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {/if}
-
- {:else if entry.data.provider === "github-copilot"}
- {@const p = Math.round(entry.data.percentUsed ?? 0)}
- <div class="flex flex-col gap-0.5 pl-1">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- {#if entry.data.tokensConsumed !== undefined && entry.data.tokensRemaining !== undefined}
- {entry.data.tokensConsumed.toLocaleString()} / {(entry.data.tokensConsumed + entry.data.tokensRemaining).toLocaleString()} tokens
- {:else if entry.data.plan}
- {entry.data.plan}
- {:else}
- Usage
- {/if}
- </span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(p / 100)}" value={p} max="100"></progress>
- {#if entry.data.resetAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
- {/if}
- </div>
- {:else if entry.data.provider === "google"}
- <div class="flex flex-col gap-0.5 pl-1">
- <!-- Cookie-scraped usage from gemini.google.com -->
- {#if entry.data.currentUsage}
- {@const u = entry.data.currentUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Current</span>
- <span class="text-xs font-mono">{u.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(u.percentUsed / 100)}" value={u.percentUsed} max="100"></progress>
- {#if u.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {u.resetsAt}</span>
- {/if}
- </div>
- {/if}
- {#if entry.data.weeklyUsage}
- {@const w = entry.data.weeklyUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{w.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(w.percentUsed / 100)}" value={w.percentUsed} max="100"></progress>
- {#if w.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {w.resetsAt}</span>
- {/if}
- </div>
- {/if}
- <!-- API key rate limits -->
- {#if !entry.data.currentUsage && entry.data.models && entry.data.models.length > 0}
- {@const m = entry.data.models[0]}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Models</span>
- <span class="text-xs font-mono">{entry.data.models.length} available</span>
- </div>
- {#if m && m.rpm > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPM</span>
- <span class="text-xs font-mono">{m.rpm}</span>
- </div>
- {/if}
- {#if m && m.requestsPerDay > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPD</span>
- <span class="text-xs font-mono">{m.requestsPerDay.toLocaleString()}</span>
- </div>
- {/if}
- {#if !entry.data.currentUsage}
- <p class="text-xs text-base-content/40 mt-0.5">Set GEMINI_COOKIE (__Secure-1PSID) for usage %</p>
- {/if}
- {/if}
- </div>
- {/if}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
deleted file mode 100644
index de202b6..0000000
--- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte
+++ /dev/null
@@ -1,174 +0,0 @@
-<script lang="ts">
-import DOMPurify from "dompurify";
-import hljs from "highlight.js/lib/core";
-// Hot set — matches roughly what ChatGPT preloads. Registered eagerly so
-// common code blocks highlight on first paint without a network roundtrip.
-import bash from "highlight.js/lib/languages/bash";
-import c from "highlight.js/lib/languages/c";
-import cpp from "highlight.js/lib/languages/cpp";
-import csharp from "highlight.js/lib/languages/csharp";
-import css from "highlight.js/lib/languages/css";
-import go from "highlight.js/lib/languages/go";
-import java from "highlight.js/lib/languages/java";
-import javascript from "highlight.js/lib/languages/javascript";
-import json from "highlight.js/lib/languages/json";
-import markdown from "highlight.js/lib/languages/markdown";
-import php from "highlight.js/lib/languages/php";
-import plaintext from "highlight.js/lib/languages/plaintext";
-import python from "highlight.js/lib/languages/python";
-import ruby from "highlight.js/lib/languages/ruby";
-import rust from "highlight.js/lib/languages/rust";
-import shell from "highlight.js/lib/languages/shell";
-import sql from "highlight.js/lib/languages/sql";
-import typescript from "highlight.js/lib/languages/typescript";
-import xml from "highlight.js/lib/languages/xml";
-import yaml from "highlight.js/lib/languages/yaml";
-import { Marked } from "marked";
-import { markedHighlight } from "marked-highlight";
-
-hljs.registerLanguage("bash", bash);
-hljs.registerLanguage("c", c);
-hljs.registerLanguage("cpp", cpp);
-hljs.registerLanguage("csharp", csharp);
-hljs.registerLanguage("css", css);
-hljs.registerLanguage("go", go);
-hljs.registerLanguage("java", java);
-hljs.registerLanguage("javascript", javascript);
-hljs.registerLanguage("json", json);
-hljs.registerLanguage("markdown", markdown);
-hljs.registerLanguage("php", php);
-hljs.registerLanguage("plaintext", plaintext);
-hljs.registerLanguage("python", python);
-hljs.registerLanguage("ruby", ruby);
-hljs.registerLanguage("rust", rust);
-hljs.registerLanguage("shell", shell);
-hljs.registerLanguage("sql", sql);
-hljs.registerLanguage("typescript", typescript);
-hljs.registerLanguage("xml", xml);
-hljs.registerLanguage("yaml", yaml);
-
-// Normalize common aliases to their canonical highlight.js language names.
-// The canonical name is what we'll attempt to dynamically import.
-const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- shell: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
- kt: "kotlin",
- ps1: "powershell",
-};
-
-function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
-}
-
-const loadCache = new Map<string, Promise<boolean>>();
-
-async function ensureLanguage(lang: string): Promise<boolean> {
- const name = normalizeLang(lang);
- if (hljs.getLanguage(name)) return true;
- if (loadCache.has(name)) return loadCache.get(name) ?? false;
-
- const promise = (async () => {
- try {
- // Dynamic import for languages not in the hot set above.
- // @vite-ignore: the variable `name` is intentionally dynamic;
- // missing modules are caught by the try/catch below.
- const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`);
- hljs.registerLanguage(name, mod.default);
- return true;
- } catch {
- return false;
- }
- })();
-
- loadCache.set(name, promise);
- return promise;
-}
-
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
-}
-
-const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- async: true,
- async highlight(code: string, lang: string): Promise<string> {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- const loaded = await ensureLanguage(name);
- if (!loaded) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- {
- gfm: true,
- breaks: true,
- },
-);
-
-const { text = "", streaming = false }: { text?: string; streaming?: boolean } = $props();
-
-function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) || []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) || []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) || []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
-}
-
-let html = $state("");
-let renderToken = 0;
-
-$effect(() => {
- const src = streaming ? closeOpenDelimiters(text) : text;
- const myToken = ++renderToken;
- (async () => {
- try {
- const raw = (await md.parse(src)) as string;
- if (myToken === renderToken) html = DOMPurify.sanitize(raw);
- } catch {
- // swallow — keeps last successful render visible
- }
- })();
-});
-</script>
-
-<div class="markdown-body">
- {@html html}
-</div>
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
deleted file mode 100644
index 64036d4..0000000
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ /dev/null
@@ -1,636 +0,0 @@
-<script module lang="ts">
-const modelCache = new Map<string, string[]>();
-</script>
-
-<script lang="ts">
- import {
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- REASONING_EFFORTS,
- REASONING_EFFORT_LABELS,
- } from "@dispatch/core/src/types/index.js";
- import type { KeyInfo } from "../types.js";
- import {
- cacheWarming,
- WARM_INTERVAL_MS,
- } from "../cache-warming.svelte.js";
- import { config } from "../config.js";
- import { router } from "../router.svelte.js";
- import { tabStore } from "../tabs.svelte.js";
-
- interface AgentInfo {
- name: string;
- slug: string;
- scope: string;
- description: string;
- skills: string[];
- tools: string[];
- models: Array<{ key_id: string; model_id: string; effort?: string }>;
- cwd?: string;
- is_subagent?: boolean;
- }
-
- // Moves an element to document.body so modals escape the sidebar's
- // transform stacking context and cover the full viewport.
- function portal(node: HTMLElement) {
- document.body.appendChild(node);
- return {
- destroy() {
- node.remove();
- },
- };
- }
-
- /**
- * Human-readable effort label for a (possibly-unset) per-model effort. When
- * the model has no explicit override, the badge reflects what will ACTUALLY
- * run: the per-tab selector if valid, else the system default. This mirrors
- * the backend resolution order (per-model → per-tab → default) so the UI
- * never misrepresents the effective effort.
- */
- function effortLabel(effort: string | undefined): string {
- if (isReasoningEffort(effort)) return REASONING_EFFORT_LABELS[effort];
- const tab = isReasoningEffort(reasoningEffort) ? reasoningEffort : DEFAULT_REASONING_EFFORT;
- return REASONING_EFFORT_LABELS[tab];
- }
-
- const {
- keys = [],
- activeTabId = null,
- activeKeyId = null,
- activeModelId = null,
- reasoningEffort = "max",
- activeAgentSlug = null,
- activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
- workingDirectory = null,
- onKeyChange,
- onModelChange,
- onReasoningChange,
- onAgentChange = (_agent: AgentInfo | null) => {},
- onWorkingDirectoryChange = (_dir: string | null) => {},
- onCompact = () => {},
- canCompact = false,
- compacting = false,
- }: {
- keys?: KeyInfo[];
- activeTabId?: string | null;
- activeKeyId?: string | null;
- activeModelId?: string | null;
- reasoningEffort?: string;
- activeAgentSlug?: string | null;
- activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
- workingDirectory?: string | null;
- onKeyChange: (keyId: string) => void;
- onModelChange: (keyId: string, modelId: string) => void;
- onReasoningChange: (effort: string) => void;
- onAgentChange?: (agent: AgentInfo | null) => void;
- onWorkingDirectoryChange?: (dir: string | null) => void;
- onCompact?: () => void;
- canCompact?: boolean;
- compacting?: boolean;
- } = $props();
-
- let showKeyModal = $state(false);
- let showModelModal = $state(false);
- let availableModels = $state<string[]>([]);
- let loadingModels = $state(false);
- let modelError = $state<string | null>(null);
- let sliderDragging = $state<number | null>(null);
- let modelSearch = $state("");
-
- // ─── Prompt-cache warming (debug strip lives at the bottom) ──────
- // Reactive per-tab warming state from the singleton store. `warm.now` is a
- // 1s ticking clock so the countdown re-renders while a fire is pending.
- const warm = $derived(cacheWarming.stateFor(activeTabId));
- const warmCountdown = $derived.by(() => {
- const next = warm.nextFireAt;
- if (next === null) return null;
- const ms = Math.max(0, next - cacheWarming.now);
- const total = Math.round(ms / 1000);
- const m = Math.floor(total / 60);
- const s = total % 60;
- return `${m}:${s.toString().padStart(2, "0")}`;
- });
- const warmIntervalLabel = `${Math.round(WARM_INTERVAL_MS / 60000)} min`;
-
- function toggleCacheWarming(enabled: boolean): void {
- if (!activeTabId) return;
- tabStore.setCacheWarmingEnabled(activeTabId, enabled);
- }
-
- let cwdExists = $state<boolean | null>(null);
- let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
-
- $effect(() => {
- const cwd = workingDirectory;
- if (!cwd) {
- cwdExists = null;
- return;
- }
- cwdExists = null;
- if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
- cwdCheckTimer = setTimeout(async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd)}`,
- );
- if (res.ok) {
- const data = await res.json();
- cwdExists = data.exists ?? false;
- }
- } catch {
- cwdExists = null;
- }
- }, 300);
- });
-
- let modeOverride = $state<"manual" | "agent" | null>(null);
- let mode = $derived(
- activeTabParentId && activeAgentSlug
- ? "subagent"
- : modeOverride ?? (activeAgentSlug ? "agent" : "manual"),
- );
- let agents = $state<AgentInfo[]>([]);
- let visibleAgents = $derived(agents.filter((a) => !a.is_subagent));
- let loadingAgents = $state(false);
-
- $effect(() => {
- fetchAgents();
- });
-
- async function fetchAgents() {
- loadingAgents = true;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (res.ok) {
- const data = await res.json();
- agents = data.agents ?? [];
- }
- } catch {
- /* ignore */
- } finally {
- loadingAgents = false;
- }
- }
-
- function selectKey(keyId: string) {
- showKeyModal = false;
- onKeyChange(keyId);
- // Immediately open model selection for the new key
- openModelModal(keyId);
- }
-
- async function openModelModal(keyIdOverride?: string) {
- const keyId = keyIdOverride ?? activeKeyId;
- if (!keyId) return;
- showModelModal = true;
- modelError = null;
- modelSearch = "";
-
- // Check session cache
- if (modelCache.has(keyId)) {
- availableModels = modelCache.get(keyId)!;
- loadingModels = false;
- return;
- }
-
- loadingModels = true;
- availableModels = [];
-
- try {
- const res = await fetch(
- `${config.apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`,
- );
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- modelError = data.error ?? `Failed to fetch models (HTTP ${res.status})`;
- return;
- }
- const data = await res.json();
- availableModels = data.models ?? [];
- // Cache for session
- modelCache.set(keyId, availableModels);
- } catch (err) {
- modelError = err instanceof Error ? err.message : "Failed to fetch models";
- } finally {
- loadingModels = false;
- }
- }
-
- function selectModel(model: string) {
- showModelModal = false;
- if (activeKeyId) {
- onModelChange(activeKeyId, model);
- }
- }
-</script>
-
-<div class="bg-base-200 rounded-lg p-3">
- <!-- Working Directory -->
- <div class="form-control mb-3">
- <label class="label py-0" for="cwd-input">
- <span class="label-text text-xs font-semibold">Working Directory</span>
- </label>
- <div class="flex items-center gap-1.5 mt-1">
- <input
- id="cwd-input"
- type="text"
- class="input input-bordered input-sm font-mono text-xs flex-1"
- placeholder="default (project root)"
- value={workingDirectory ?? ""}
- onchange={(e) => {
- const val = e.currentTarget.value.trim();
- onWorkingDirectoryChange(val || null);
- }}
- />
- {#if workingDirectory}
- {#if cwdExists === true}
- <span class="text-success text-sm" title="Directory exists">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-sm" title="Will be created">&#x2716;</span>
- {:else}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {/if}
- </div>
- </div>
-
- <!-- Compact conversation -->
- <div class="mb-3">
- <button
- type="button"
- class="btn btn-sm btn-outline w-full"
- disabled={!canCompact || compacting}
- onclick={onCompact}
- title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done."
- >
- {#if compacting}
- <span class="loading loading-spinner loading-xs"></span>
- Compacting…
- {:else}
- Compact conversation
- {/if}
- </button>
- </div>
-
- <!-- Toggle -->
- <div class="flex items-center gap-2 mb-3">
- <button
- class="btn btn-xs {mode === 'manual' ? 'btn-primary' : 'btn-ghost'}"
- onclick={() => { modeOverride = "manual"; onAgentChange(null); }}
- disabled={mode === 'subagent'}
- >
- Manual
- </button>
- <button
- class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}"
- onclick={async () => {
- modeOverride = "agent";
- await fetchAgents();
- // Re-apply the active agent's settings (including cwd)
- const current = visibleAgents.find(a => a.slug === activeAgentSlug);
- const agentToApply = current ?? visibleAgents[0] ?? null;
- if (agentToApply) {
- onAgentChange(agentToApply);
- // Force-update the input since the prop may not change (already set)
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agentToApply.cwd ?? "";
- }
- }}
- disabled={mode === 'subagent'}
- >
- Agent
- </button>
- <button
- class="btn btn-xs {mode === 'subagent' ? 'btn-primary' : 'btn-ghost'}"
- disabled={true}
- >
- SubAgent
- </button>
- </div>
-
- {#if mode === "manual"}
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
- {activeKeyId ?? "Select Key"}
- </button>
- </div>
-
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Model</span>
- <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
- {activeModelId ?? "Select Model"}
- </button>
- </div>
-
- {#if activeModelId}
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Thinking</span>
- <select
- class="select select-bordered select-sm"
- value={reasoningEffort}
- onchange={(e) => onReasoningChange(e.currentTarget.value)}
- >
- {#each REASONING_EFFORTS as effort}
- <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
- {/each}
- </select>
- </div>
- {/if}
- {:else if mode === "subagent"}
- <!-- SubAgent read-only info -->
- {@const subModels = activeAgentModels ?? []}
- {@const hasSubModels = subModels.length > 1}
- {@const subActiveIdx = subModels.findIndex(
- (m) => m.key_id === activeKeyId && m.model_id === activeModelId,
- )}
- <div class="flex flex-col gap-2">
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">SubAgent</span>
- <span class="badge badge-sm">{activeAgentSlug}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <span class="font-mono text-xs">{activeKeyId ?? "default"}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Model</span>
- <span class="font-mono text-xs">{activeModelId ?? "default"}</span>
- </div>
- {#if hasSubModels}
- {@const displayIdx = subActiveIdx >= 0 ? subActiveIdx : 0}
- <div class="mt-1 pt-2 border-t border-base-content/20">
- <div class="text-xs font-semibold mb-1">Model fallback chain</div>
- <input
- type="range"
- min="0"
- max={subModels.length - 1}
- value={displayIdx}
- class="range range-xs"
- step="1"
- disabled
- />
- <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5">
- {#each subModels as _, i}
- <span>{i + 1}</span>
- {/each}
- </div>
- <div class="mt-1 flex flex-col gap-0.5">
- {#each subModels as m, i}
- <div class="text-xs font-mono truncate flex items-center gap-1 {i === displayIdx ? 'opacity-100 font-semibold' : 'opacity-50'}">
- <span class="truncate">{i + 1}. {m.key_id} / {m.model_id}</span>
- <span class="badge badge-xs badge-ghost shrink-0">{effortLabel(m.effort)}</span>
- </div>
- {/each}
- </div>
- </div>
- {/if}
- <p class="text-xs text-base-content/50 mt-1">This tab was spawned by a parent agent. Settings cannot be changed.</p>
- </div>
- {:else}
- <!-- Agent selection UI -->
- {#if loadingAgents}
- <div class="flex items-center gap-2 py-2 text-base-content/60">
- <span class="loading loading-spinner loading-xs"></span>
- Loading agents...
- </div>
- {:else if visibleAgents.length === 0}
- <p class="text-base-content/50 text-sm py-2">No agents configured.</p>
- {:else}
- <div class="flex flex-col gap-1.5">
- {#each visibleAgents as agent (agent.slug + ":" + agent.scope)}
- {@const isActive = activeAgentSlug === agent.slug}
- {@const hasMultipleModels = agent.models.length > 1}
- {@const currentIdx = isActive
- ? agent.models.findIndex(
- (m) => m.key_id === activeKeyId && m.model_id === activeModelId,
- )
- : -1}
- <div
- role="button"
- tabindex="0"
- class="w-full text-left rounded-lg px-3 py-2 transition-colors {isActive ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}"
- onclick={() => {
- // Only switch agent — don't reset the slider position
- onAgentChange(agent);
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agent.cwd ?? "";
- }}
- onkeydown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onAgentChange(agent);
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agent.cwd ?? "";
- }
- }}
- >
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium text-sm">{agent.name}</span>
- <div class="flex gap-1 shrink-0">
- <span class="badge badge-xs">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-xs badge-outline">{agent.scope === "global" ? "global" : "project"}</span>
- </div>
- </div>
- {#if agent.description}
- <p class="text-xs opacity-60 mt-0.5">{agent.description}</p>
- {/if}
- {#if isActive && hasMultipleModels}
- {@const displayIdx = sliderDragging !== null ? sliderDragging : (currentIdx >= 0 ? currentIdx : 0)}
- {@const displayModel = agent.models[displayIdx]}
- <div class="mt-2 pt-2 border-t border-primary-content/20">
- <div class="text-xs font-semibold mb-1 truncate flex items-center gap-1">
- <span class="truncate">{displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`}</span>
- <span class="badge badge-xs shrink-0">{effortLabel(displayModel?.effort)}</span>
- </div>
- <input
- type="range"
- min="0"
- max={agent.models.length - 1}
- value={currentIdx >= 0 ? currentIdx : 0}
- class="range range-xs"
- step="1"
- oninput={(e) => {
- sliderDragging = Number(e.currentTarget.value);
- }}
- onchange={(e) => {
- const idx = Number(e.currentTarget.value);
- const m = agent.models[idx];
- if (m) onModelChange(m.key_id, m.model_id);
- sliderDragging = null;
- }}
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => e.stopPropagation()}
- />
- <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5">
- {#each agent.models as _, i}
- <span>{i + 1}</span>
- {/each}
- </div>
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <button
- type="button"
- class="btn btn-outline btn-sm w-full mt-2 hover:bg-base-300 hover:border-base-300 text-base-content/60"
- onclick={() => router.navigate("agent-builder")}
- >
- Agent Settings
- </button>
- {/if}
-
- <!-- Prompt-cache warming (bottom of the Chat Settings panel) -->
- <div class="mt-3 pt-3 border-t border-base-300">
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- checked={warm.enabled}
- disabled={!activeTabId}
- onchange={(e) => toggleCacheWarming(e.currentTarget.checked)}
- />
- <span class="text-xs font-semibold">Keep prompt cache warm</span>
- </label>
- <p class="text-[10px] text-base-content/40 mt-1 leading-snug">
- While this tab is idle, replays the cached conversation every {warmIntervalLabel}
- so the provider cache stays warm for your next message. Warming traffic is
- debug-only — it never touches history, the Cache Rate metric, or context size.
- </p>
-
- {#if warm.enabled}
- <div class="mt-2 flex flex-col gap-2 bg-base-300/40 rounded-lg p-2">
- <!-- Warming "last request" cache rate (separate from the real metric) -->
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Last request (warming)</span>
- <span class="text-xs font-mono">{warm.lastPct === null ? "-%" : `${warm.lastPct}%`}</span>
- </div>
- <progress
- class="progress w-full h-2 {warm.lastPct === null
- ? ''
- : warm.lastPct >= 70
- ? 'progress-success'
- : warm.lastPct >= 30
- ? 'progress-warning'
- : 'progress-error'}"
- value={warm.lastPct ?? 0}
- max="100"
- ></progress>
- </div>
-
- <!-- Countdown to the next warming fire -->
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Next warm in</span>
- <span class="text-xs font-mono">
- {#if warm.firing}
- warming…
- {:else if warmCountdown !== null}
- {warmCountdown}
- {:else}
- —
- {/if}
- </span>
- </div>
-
- {#if warm.error}
- <div class="text-[10px] text-error break-words">
- {warm.error}
- </div>
- {/if}
- </div>
- {/if}
- </div>
-</div>
-
-{#if showKeyModal}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Key</h3>
- <div class="mt-4 flex flex-col gap-2">
- {#each keys as key}
- <button
- class="btn {key.id === activeKeyId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start text-base"
- onclick={() => selectKey(key.id)}
- >
- <span class="font-mono">{key.id}</span>
- <span class="badge ml-auto">{key.provider}</span>
- <span
- class="badge {key.status === 'active'
- ? 'badge-success'
- : 'badge-error'}">{key.status}</span
- >
- </button>
- {/each}
- </div>
- <div class="modal-action">
- <button class="btn" onclick={() => (showKeyModal = false)}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => (showKeyModal = false)} aria-label="Close modal"></button>
- </div>
-{/if}
-
-{#if showModelModal}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Model</h3>
- {#if loadingModels}
- <div class="flex justify-center py-8">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else if modelError}
- <div class="alert alert-error mt-4 text-base">
- <span>{modelError}</span>
- </div>
- {:else}
- {@const search = modelSearch.toLowerCase().trim()}
- {@const searchRegex = search
- ? new RegExp(
- search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "[ _-]"),
- )
- : null}
- {@const filteredModels = searchRegex
- ? availableModels.filter((m) => searchRegex.test(m.toLowerCase()))
- : availableModels}
- <div class="mt-4 flex flex-col gap-1">
- <input
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="Filter models..."
- bind:value={modelSearch}
- />
- <div class="mt-2 max-h-96 overflow-y-auto flex flex-col gap-1">
- {#each filteredModels as model}
- <button
- class="btn {model === activeModelId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
- {#if filteredModels.length === 0}
- <p class="text-xs text-base-content/50 py-2 text-center">
- {search ? 'No models match your search.' : 'No models available.'}
- </p>
- {/if}
- </div>
- </div>
- {/if}
- <div class="modal-action">
- <button class="btn" onclick={() => (showModelModal = false)}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => (showModelModal = false)} aria-label="Close modal"></button>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
deleted file mode 100644
index 57c5efd..0000000
--- a/packages/frontend/src/lib/components/ModelStatus.svelte
+++ /dev/null
@@ -1,356 +0,0 @@
-<script lang="ts">
-interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
-}
-
-interface CredentialStatus {
- keyId: string;
- provider: string;
- subscriptionType: string | null;
- importedAt: number;
- updatedAt: number;
- expired: boolean;
-}
-
-const {
- keys = [],
- currentModel,
- apiBase = "",
- onAddKey = () => {},
-}: {
- keys?: KeyInfo[];
- currentModel?: string;
- apiBase?: string;
- onAddKey?: () => void;
-} = $props();
-
-const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
-const totalKeys = $derived(keys.length);
-const allActive = $derived(totalKeys > 0 && activeKeys === totalKeys);
-const allExhausted = $derived(totalKeys > 0 && activeKeys === 0);
-const someExhausted = $derived(totalKeys > 0 && activeKeys < totalKeys && activeKeys > 0);
-
-let credentialStatus = $state<Record<string, CredentialStatus>>({});
-let importingKey = $state<string | null>(null);
-let importError = $state<string | null>(null);
-let importSuccess = $state<string | null>(null);
-
-let apiKeyStatus = $state<
- Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>
->({});
-let showKeyModal = $state<string | null>(null); // keyId or null
-let keyModalValue = $state("");
-let keyModalError = $state<string | null>(null);
-let keyModalSaving = $state(false);
-let removingKey = $state<string | null>(null);
-
-async function loadCredentialStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/credentials-status`);
- if (!res.ok) return;
- const data = (await res.json()) as { credentials: CredentialStatus[] };
- const map: Record<string, CredentialStatus> = {};
- for (const cred of data.credentials) {
- map[cred.keyId] = cred;
- }
- credentialStatus = map;
- } catch {
- // ignore
- }
-}
-
-async function importCredentials(keyId: string): Promise<void> {
- importingKey = keyId;
- importError = null;
- importSuccess = null;
- try {
- const res = await fetch(`${apiBase}/models/import-credentials`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- importError = data.error ?? "Import failed";
- } else {
- importSuccess = keyId;
- await loadCredentialStatus();
- setTimeout(() => {
- importSuccess = null;
- }, 3000);
- }
- } catch (e) {
- importError = e instanceof Error ? e.message : "Network error";
- } finally {
- importingKey = null;
- }
-}
-
-async function loadApiKeyStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/api-keys-status`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }>;
- };
- const map: Record<string, (typeof data.keys)[0]> = {};
- for (const k of data.keys) {
- map[k.keyId] = k;
- }
- apiKeyStatus = map;
- } catch {
- // ignore
- }
-}
-
-async function saveApiKey(): Promise<void> {
- if (!showKeyModal || !keyModalValue.trim()) return;
- keyModalSaving = true;
- keyModalError = null;
- try {
- const res = await fetch(`${apiBase}/models/set-api-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- keyModalError = data.error ?? "Failed to save";
- } else {
- showKeyModal = null;
- keyModalValue = "";
- await loadApiKeyStatus();
- }
- } catch (e) {
- keyModalError = e instanceof Error ? e.message : "Network error";
- } finally {
- keyModalSaving = false;
- }
-}
-
-async function removeKey(keyId: string): Promise<void> {
- if (!confirm(`Remove key "${keyId}" from config?`)) return;
- removingKey = keyId;
- try {
- const res = await fetch(`${apiBase}/models/remove-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: keyId }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (res.ok && data.success) {
- await new Promise((r) => setTimeout(r, 500));
- window.location.reload();
- }
- } catch {
- // ignore
- } finally {
- removingKey = null;
- }
-}
-
-$effect(() => {
- void loadCredentialStatus();
- void loadApiKeyStatus();
-});
-
-function timeAgo(ts: number | null): string {
- if (ts === null) return "";
- const diffMs = Date.now() - ts;
- const diffSec = Math.floor(diffMs / 1000);
- if (diffSec < 60) return `${diffSec}s ago`;
- const diffMin = Math.floor(diffSec / 60);
- if (diffMin < 60) return `${diffMin}m ago`;
- const diffHr = Math.floor(diffMin / 60);
- return `${diffHr}h ago`;
-}
-
-function truncate(str: string | null, max: number): string {
- if (!str) return "";
- return str.length > max ? `${str.slice(0, max)}...` : str;
-}
-</script>
-
-<div class="flex flex-col gap-3">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">
- No models configured. Using environment defaults.
- </p>
- {:else}
- <!-- Overall status -->
- {#if allActive}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-success badge-xs">●</span>
- <span class="text-xs text-success">All keys available</span>
- </div>
- {:else if allExhausted}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-error badge-xs">●</span>
- <span class="text-xs text-error">All keys exhausted — waiting for refresh</span>
- </div>
- {:else if someExhausted}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-warning badge-xs">●</span>
- <span class="text-xs text-warning">
- Fallback active ({activeKeys}/{totalKeys} keys available)
- </span>
- </div>
- {/if}
-
- <!-- Current model -->
- {#if currentModel}
- <div class="flex flex-col gap-0.5">
- <p class="text-xs text-base-content/50 uppercase tracking-wide">Current Model</p>
- <p class="text-sm font-mono font-semibold text-primary">{currentModel}</p>
- </div>
- {/if}
-
- <!-- Import error/success banners -->
- {#if importError}
- <div role="alert" class="text-xs text-error/80">{importError}</div>
- {/if}
- {#if importSuccess}
- <div class="text-xs text-success/80">Imported credentials for {importSuccess}</div>
- {/if}
-
- <!-- Keys -->
- {#if keys.length > 0}
- <div class="flex flex-col gap-1">
- <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p>
- <ul class="flex flex-col gap-1">
- {#each keys as key (key.id)}
- {@const cred = credentialStatus[key.id]}
- <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors">
- <div class="flex items-center gap-1.5">
- <span
- class="badge badge-xs {key.status === 'active'
- ? 'badge-success'
- : 'badge-error'}"
- >
- {key.status}
- </span>
- <span class="text-xs font-mono flex-1">{key.id}</span>
- <span class="text-xs text-base-content/40">{key.provider}</span>
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-square text-base-content/30 hover:text-error"
- disabled={removingKey === key.id}
- onclick={() => removeKey(key.id)}
- title="Remove key"
- >
- {#if removingKey === key.id}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- ✕
- {/if}
- </button>
- </div>
- {#if key.status === "exhausted"}
- <div class="pl-2 flex flex-col gap-0.5">
- {#if key.lastError}
- <p class="text-xs text-error/70 line-clamp-1">
- {truncate(key.lastError, 80)}
- </p>
- {/if}
- {#if key.exhaustedAt !== null}
- <p class="text-xs text-base-content/40">{timeAgo(key.exhaustedAt)}</p>
- {/if}
- </div>
- {/if}
- <!-- Credential import for anthropic keys -->
- {#if key.provider === "anthropic"}
- <div class="pl-2 flex items-center gap-1.5 mt-0.5">
- {#if cred}
- <span class="badge badge-xs {cred.expired ? 'badge-warning' : 'badge-info'}">
- {cred.expired ? "expired" : "imported"}
- </span>
- {#if cred.subscriptionType}
- <span class="text-xs text-base-content/40">{cred.subscriptionType}</span>
- {/if}
- {/if}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline"
- disabled={importingKey === key.id}
- onclick={() => importCredentials(key.id)}
- >
- {#if importingKey === key.id}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- {cred ? "Re-import" : "Import Credentials"}
- {/if}
- </button>
- </div>
- {/if}
- <!-- API key import for env-based keys (opencode, copilot, etc) -->
- {#if key.provider !== "anthropic"}
- <div class="pl-2 flex items-center gap-1.5 mt-0.5">
- {#if apiKeyStatus[key.id]}
- <span class="badge badge-xs badge-info">imported</span>
- {/if}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline"
- onclick={() => { showKeyModal = key.id; keyModalValue = ""; keyModalError = null; }}
- >
- {apiKeyStatus[key.id] ? "Update Key" : "Import Key"}
- </button>
- </div>
- {/if}
- </li>
- {/each}
- </ul>
- </div>
- {/if}
- {/if}
- <button
- type="button"
- class="btn btn-sm btn-primary btn-outline w-full mt-2"
- onclick={onAddKey}
- >
- + Add New Key
- </button>
-<!-- Import Key Modal -->
-{#if showKeyModal}
- <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
- <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
- <h3 class="text-sm font-semibold">Import Key: {showKeyModal}</h3>
- <input
- type="password"
- class="input input-bordered input-sm w-full"
- placeholder="Paste API key..."
- bind:value={keyModalValue}
- />
- {#if keyModalError}
- <p class="text-xs text-error">{keyModalError}</p>
- {/if}
- <div class="flex justify-end gap-2">
- <button
- type="button"
- class="btn btn-sm btn-ghost"
- onclick={() => { showKeyModal = null; keyModalValue = ""; keyModalError = null; }}
- >
- Cancel
- </button>
- <button
- type="button"
- class="btn btn-sm btn-primary"
- disabled={!keyModalValue.trim() || keyModalSaving}
- onclick={saveApiKey}
- >
- {#if keyModalSaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
-
-</div>
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte
deleted file mode 100644
index 3a67b30..0000000
--- a/packages/frontend/src/lib/components/PermissionPrompt.svelte
+++ /dev/null
@@ -1,100 +0,0 @@
-<script lang="ts">
-import type { PermissionPrompt } from "../types.js";
-
-const {
- pending,
- onReply,
-}: {
- pending: PermissionPrompt[];
- onReply: (id: string, reply: "once" | "always" | "reject") => void;
-} = $props();
-
-let current = $derived(pending[0]);
-
-let showAlwaysConfirmation = $state(false);
-
-let dialogEl: HTMLDialogElement | undefined = $state();
-
-$effect(() => {
- if (!dialogEl) return;
- if (current) {
- if (!dialogEl.open) dialogEl.showModal();
- } else {
- if (dialogEl.open) dialogEl.close();
- }
-});
-
-function handleAlways() {
- showAlwaysConfirmation = true;
-}
-
-function confirmAlways() {
- if (current) onReply(current.id, "always");
- showAlwaysConfirmation = false;
-}
-
-function handleOnce() {
- if (current) onReply(current.id, "once");
-}
-
-function handleReject() {
- showAlwaysConfirmation = false;
- if (current) onReply(current.id, "reject");
-}
-</script>
-
-<dialog class="modal" bind:this={dialogEl} oncancel={handleReject}>
- {#if current}
- {#if !showAlwaysConfirmation}
- <div class="modal-box">
- <h3 class="text-lg font-bold">
- {#if current.permission === "bash"}
- Run command
- {:else if current.permission === "external_directory"}
- Access external directory
- {:else if current.permission === "read"}
- Read file
- {:else if current.permission === "edit"}
- Edit file
- {:else}
- Permission required
- {/if}
- </h3>
-
- <p class="py-2 text-sm opacity-70">{current.description}</p>
-
- {#if current.permission === "bash" && current.metadata.command}
- <div class="mockup-code my-2">
- <pre><code>$ {current.metadata.command as string}</code></pre>
- </div>
- {/if}
-
- {#if current.metadata.filepath}
- <p class="text-sm font-mono">{current.metadata.filepath as string}</p>
- {/if}
-
- <div class="modal-action gap-2">
- <button class="btn btn-sm btn-ghost" aria-label="Deny permission" onclick={handleReject}>Deny</button>
- <button class="btn btn-sm" aria-label="Allow once" onclick={handleOnce}>Allow once</button>
- <button class="btn btn-sm btn-primary" aria-label="Always allow" onclick={handleAlways}>Always allow</button>
- </div>
- </div>
- {:else}
- <div class="modal-box">
- <h3 class="text-lg font-bold">Always allow?</h3>
- <p class="py-2 text-sm">
- The following patterns will be permanently allowed until you restart Dispatch:
- </p>
- <div class="mockup-code my-2">
- {#each current.always as pattern}
- <pre><code>{pattern}</code></pre>
- {/each}
- </div>
- <div class="modal-action gap-2">
- <button class="btn btn-sm btn-ghost" aria-label="Go back" onclick={() => showAlwaysConfirmation = false}>Back</button>
- <button class="btn btn-sm btn-primary" aria-label="Confirm always allow" onclick={confirmAlways}>Confirm</button>
- </div>
- </div>
- {/if}
- {/if}
-</dialog>
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
deleted file mode 100644
index 8b28957..0000000
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ /dev/null
@@ -1,643 +0,0 @@
-<script lang="ts">
-import { config } from "../config.js";
-import { appSettings } from "../settings.svelte.js";
-import { applyTheme, loadStoredTheme, THEMES, type Theme } from "../theme.js";
-import type { KeyInfo } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-// Theme picker — was a header-triggered modal (`ThemeSwitcher.svelte`);
-// inlined here so theme picking lives in Settings alongside other UI
-// preferences. Theme constants and apply/persist live in `../theme.ts`
-// so the boot-time apply in `App.svelte` and this picker can't drift.
-let currentTheme = $state<Theme>(loadStoredTheme());
-
-function selectTheme(theme: Theme): void {
- currentTheme = theme;
- applyTheme(theme);
-}
-
-let titleKeyId = $state<string | null>(null);
-let titleModelId = $state<string | null>(null);
-let availableModels = $state<string[]>([]);
-let compactionKeyId = $state<string | null>(null);
-let compactionModelId = $state<string | null>(null);
-let compactionModels = $state<string[]>([]);
-let loadingCompactionModels = $state(false);
-let loadingModels = $state(false);
-let autoExpandThinking = $state(appSettings.autoExpandThinking);
-let localChunkLimit = $state(appSettings.chunkLimit);
-let backendUrl = $state(config.apiBase);
-let backendUrlSaved = $state(false);
-
-// ─── ntfy.sh push notifications ──────────────────────────────────
-// Server-side schema mirror — kept inline rather than imported to avoid
-// pulling a node-only barrel into the browser bundle (frontend already
-// hand-mirrors a few core types in lib/types.ts for the same reason).
-type NotificationEventType =
- | "turn-completed"
- | "turn-error"
- | "permission-required"
- | "agent-spawned";
-
-interface NtfyConfigView {
- enabled: boolean;
- topic: string;
- authToken: string;
- hasAuthToken?: boolean;
- events: Record<NotificationEventType, boolean>;
- notifySubagents: boolean;
-}
-
-const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = {
- "turn-completed": "Turn completed",
- "turn-error": "Turn error",
- "permission-required": "Permission requested",
- "agent-spawned": "User agent spawned",
-};
-
-const DEFAULT_NTFY: NtfyConfigView = {
- enabled: false,
- topic: "",
- authToken: "",
- hasAuthToken: false,
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
-};
-
-let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } });
-let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save
-let ntfyEventOrder = $state<NotificationEventType[]>([
- "turn-completed",
- "turn-error",
- "permission-required",
- "agent-spawned",
-]);
-let ntfySaving = $state(false);
-let ntfySaveError = $state<string | null>(null);
-let ntfySaveOk = $state(false);
-let ntfyTesting = $state(false);
-let ntfyTestResult = $state<string | null>(null);
-let ntfyTestOk = $state(false);
-let ntfyClearingToken = $state(false);
-
-function onChunkLimitChange(e: Event): void {
- const input = e.target as HTMLInputElement;
- const val = parseInt(input.value, 10);
- if (val >= 10 && val <= 2000) {
- appSettings.chunkLimit = val;
- localChunkLimit = val;
- }
-}
-
-function saveBackendUrl(): void {
- const trimmed = backendUrl.trim().replace(/\/+$/, "");
- if (!trimmed) return;
- config.setApiBase(trimmed);
- backendUrl = trimmed;
- backendUrlSaved = true;
- setTimeout(() => {
- backendUrlSaved = false;
- }, 2000);
-}
-
-function resetBackendUrl(): void {
- config.setApiBase(config.defaultApiBase);
- backendUrl = config.defaultApiBase;
- backendUrlSaved = true;
- setTimeout(() => {
- backendUrlSaved = false;
- }, 2000);
-}
-
-async function loadSettings(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/title-model`);
- if (res.ok) {
- const data = (await res.json()) as { keyId: string | null; modelId: string | null };
- titleKeyId = data.keyId;
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- titleModelId = data.modelId;
- }
- } catch {
- // ignore
- }
- try {
- const res = await fetch(`${apiBase}/tabs/settings/compaction-model`);
- if (res.ok) {
- const data = (await res.json()) as { keyId: string | null; modelId: string | null };
- compactionKeyId = data.keyId;
- if (compactionKeyId) {
- await loadCompactionModelsForKey(compactionKeyId);
- }
- compactionModelId = data.modelId;
- }
- } catch {
- // ignore
- }
- try {
- const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- autoExpandThinking = data.value === "true";
- appSettings.autoExpandThinking = autoExpandThinking;
- }
- } catch {
- // ignore
- }
- await loadNtfy();
-}
-
-async function loadNtfy(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/notifications`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- config: NtfyConfigView;
- eventTypes?: NotificationEventType[];
- };
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- if (Array.isArray(data.eventTypes) && data.eventTypes.length > 0) {
- ntfyEventOrder = data.eventTypes;
- }
- } catch {
- // ignore
- }
-}
-
-async function saveNtfy(): Promise<void> {
- ntfySaving = true;
- ntfySaveError = null;
- ntfySaveOk = false;
- try {
- // `authToken: undefined` ⇒ server keeps the existing token.
- // `authToken: ""` ⇒ explicit clear (the user typed and cleared).
- const payload: Partial<NtfyConfigView> & { authToken?: string } = {
- enabled: ntfy.enabled,
- topic: ntfy.topic,
- events: ntfy.events,
- notifySubagents: ntfy.notifySubagents,
- };
- if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput;
- const res = await fetch(`${apiBase}/notifications`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- const data = (await res.json()) as { config?: NtfyConfigView; error?: string };
- if (!res.ok) {
- ntfySaveError = data.error ?? `Save failed (HTTP ${res.status})`;
- return;
- }
- if (data.config) {
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- }
- ntfyAuthTokenInput = "";
- ntfySaveOk = true;
- setTimeout(() => {
- ntfySaveOk = false;
- }, 2000);
- } catch (e) {
- ntfySaveError = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfySaving = false;
- }
-}
-
-async function sendNtfyTest(): Promise<void> {
- ntfyTesting = true;
- ntfyTestResult = null;
- ntfyTestOk = false;
- try {
- const res = await fetch(`${apiBase}/notifications/test`, { method: "POST" });
- const data = (await res.json()) as { ok?: boolean; error?: string; status?: number };
- if (!res.ok || !data.ok) {
- ntfyTestResult = data.error ?? `Test failed (HTTP ${res.status})`;
- return;
- }
- ntfyTestOk = true;
- ntfyTestResult = "Sent — check your ntfy client.";
- } catch (e) {
- ntfyTestResult = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfyTesting = false;
- }
-}
-
-async function clearNtfyAuthToken(): Promise<void> {
- // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing).
- // Optimistic local state on failure caused a real bug pre-review: UI showed
- // the token cleared while the server still held it, then "Save" treated the
- // blank input as "keep existing" and silently re-armed the old token. Await
- // the response and only flip local state on success.
- ntfyClearingToken = true;
- ntfySaveError = null;
- try {
- const res = await fetch(`${apiBase}/notifications`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ authToken: "" }),
- });
- const data = (await res.json().catch(() => ({}))) as {
- config?: NtfyConfigView;
- error?: string;
- };
- if (!res.ok) {
- ntfySaveError = data.error ?? `Clear failed (HTTP ${res.status})`;
- return;
- }
- ntfyAuthTokenInput = "";
- if (data.config) {
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- } else {
- ntfy = { ...ntfy, hasAuthToken: false };
- }
- } catch (e) {
- ntfySaveError = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfyClearingToken = false;
- }
-}
-
-async function toggleAutoExpand(): Promise<void> {
- autoExpandThinking = !autoExpandThinking;
- appSettings.autoExpandThinking = autoExpandThinking;
- fetch(`${apiBase}/tabs/settings/auto-expand-thinking`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: String(autoExpandThinking) }),
- }).catch(() => {});
-}
-
-async function loadModelsForKey(keyId: string): Promise<void> {
- loadingModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) {
- availableModels = [];
- return;
- }
- const data = (await res.json()) as { models: string[] };
- availableModels = data.models ?? [];
- } catch {
- availableModels = [];
- } finally {
- loadingModels = false;
- }
-}
-
-function saveTitleModel(): void {
- fetch(`${apiBase}/tabs/settings/title-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }),
- }).catch(() => {});
-}
-
-async function onKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleKeyId = select.value || null;
- titleModelId = null;
- availableModels = [];
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- saveTitleModel();
-}
-
-async function onModelChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleModelId = select.value || null;
- saveTitleModel();
-}
-
-async function loadCompactionModelsForKey(keyId: string): Promise<void> {
- loadingCompactionModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) {
- compactionModels = [];
- return;
- }
- const data = (await res.json()) as { models: string[] };
- compactionModels = data.models ?? [];
- } catch {
- compactionModels = [];
- } finally {
- loadingCompactionModels = false;
- }
-}
-
-function saveCompactionModel(): void {
- fetch(`${apiBase}/tabs/settings/compaction-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: compactionKeyId, modelId: compactionModelId }),
- }).catch(() => {});
-}
-
-async function onCompactionKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- compactionKeyId = select.value || null;
- compactionModelId = null;
- compactionModels = [];
- if (compactionKeyId) {
- await loadCompactionModelsForKey(compactionKeyId);
- }
- saveCompactionModel();
-}
-
-function onCompactionModelChange(e: Event): void {
- const select = e.target as HTMLSelectElement;
- compactionModelId = select.value || null;
- saveCompactionModel();
-}
-
-$effect(() => {
- void loadSettings();
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Settings</div>
-
- <div class="flex flex-col gap-2">
- <p class="text-xs text-base-content/70">Theme</p>
- <label class="text-xs text-base-content/60">
- Appearance
- <select
- class="select select-bordered select-sm w-full capitalize"
- value={currentTheme}
- onchange={(e) => selectTheme(e.currentTarget.value as Theme)}
- >
- {#each THEMES as theme (theme)}
- <option value={theme} class="capitalize">{theme}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Title Generation Model</p>
- <p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p>
-
- <label class="text-xs text-base-content/60">
- Key
- <select class="select select-bordered select-sm w-full" onchange={onKeyChange} value={titleKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
- </label>
-
- <label class="text-xs text-base-content/60">
- Model
- <select
- class="select select-bordered select-sm w-full"
- onchange={onModelChange}
- value={titleModelId ?? ""}
- disabled={!titleKeyId || loadingModels}
- >
- <option value="">{loadingModels ? "Loading models..." : "Select a model..."}</option>
- {#each availableModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Conversation Compaction Model</p>
- <p class="text-xs text-base-content/40">Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.</p>
-
- <label class="text-xs text-base-content/60">
- Key
- <select class="select select-bordered select-sm w-full" onchange={onCompactionKeyChange} value={compactionKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
- </label>
-
- <label class="text-xs text-base-content/60">
- Model
- <select
- class="select select-bordered select-sm w-full"
- onchange={onCompactionModelChange}
- value={compactionModelId ?? ""}
- disabled={!compactionKeyId || loadingCompactionModels}
- >
- <option value="">{loadingCompactionModels ? "Loading models..." : "Select a model..."}</option>
- {#each compactionModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Chat</p>
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- checked={autoExpandThinking}
- onchange={toggleAutoExpand}
- />
- <span class="text-xs text-base-content/70">Auto-expand thinking</span>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Memory</p>
- <label class="flex flex-col gap-1">
- <span class="text-xs text-base-content/70">
- Max chunks in memory: <span class="font-semibold">{localChunkLimit}</span>
- </span>
- <input
- type="range"
- min="20"
- max="1000"
- step="10"
- class="range range-xs"
- value={localChunkLimit}
- oninput={onChunkLimitChange}
- />
- <span class="text-[10px] text-base-content/40">Lower = less RAM. Higher = less re-fetching.</span>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Backend URL</p>
- <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p>
- <div class="flex gap-1">
- <input
- type="text"
- class="input input-bordered input-sm flex-1"
- bind:value={backendUrl}
- placeholder={config.defaultApiBase}
- />
- <button type="button" class="btn btn-sm btn-primary" onclick={saveBackendUrl}>
- Save
- </button>
- </div>
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline w-full"
- disabled={config.apiBase === config.defaultApiBase}
- onclick={resetBackendUrl}
- >
- Reset to default
- </button>
- {#if backendUrlSaved}
- <p class="text-xs text-success">Saved. Reload the page to apply.</p>
- {/if}
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Notifications (ntfy.sh)</p>
- <p class="text-xs text-base-content/40">
- Push notifications to your phone when things happen here. Subscribe to your topic in the
- <a href="https://ntfy.sh/" target="_blank" rel="noopener" class="link">ntfy.sh</a> app to receive them.
- </p>
-
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.enabled}
- />
- <span class="text-xs text-base-content/70">Enable notifications</span>
- </label>
-
- <label class="text-xs text-base-content/60 flex flex-col gap-1">
- Topic
- <input
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="your-secret-topic"
- bind:value={ntfy.topic}
- />
- <span class="text-[10px] text-base-content/40">
- Any string — pick something unguessable, since anyone with the topic name can read your notifications. Subscribe to the same topic in the ntfy app.
- </span>
- </label>
-
- <label class="text-xs text-base-content/60 flex flex-col gap-1">
- Auth token (optional, for private ntfy servers)
- <input
- type="password"
- class="input input-bordered input-sm w-full"
- placeholder={ntfy.hasAuthToken ? "•••• (stored — type to replace)" : "Leave blank for public ntfy.sh"}
- bind:value={ntfyAuthTokenInput}
- autocomplete="off"
- />
- {#if ntfy.hasAuthToken}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline self-start"
- disabled={ntfyClearingToken}
- onclick={clearNtfyAuthToken}
- >
- {#if ntfyClearingToken}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Clear stored token
- {/if}
- </button>
- {/if}
- </label>
-
- <div class="flex flex-col gap-1 mt-1">
- <span class="text-xs text-base-content/60">Notify me on:</span>
- {#each ntfyEventOrder as evType (evType)}
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.events[evType]}
- />
- <span class="text-xs text-base-content/70">{NTFY_EVENT_LABELS[evType] ?? evType}</span>
- </label>
- {/each}
- </div>
-
- <div class="flex flex-col gap-1 mt-1">
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.notifySubagents}
- />
- <span class="text-xs text-base-content/70">Include subagent tabs</span>
- </label>
- <span class="text-[10px] text-base-content/40 pl-6">
- Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang.
- </span>
- </div>
-
- <div class="flex gap-1 mt-1">
- <button
- type="button"
- class="btn btn-sm btn-primary flex-1"
- disabled={ntfySaving}
- onclick={saveNtfy}
- >
- {#if ntfySaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={ntfyTesting || !ntfy.enabled}
- onclick={sendNtfyTest}
- title={ntfy.enabled ? "Send a test notification with current settings" : "Enable notifications first"}
- >
- {#if ntfyTesting}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Send test
- {/if}
- </button>
- </div>
- {#if ntfySaveOk}
- <p class="text-xs text-success">Saved.</p>
- {/if}
- {#if ntfySaveError}
- <p class="text-xs text-error">{ntfySaveError}</p>
- {/if}
- {#if ntfyTestResult}
- <p class="text-xs {ntfyTestOk ? 'text-success' : 'text-error'}">{ntfyTestResult}</p>
- {/if}
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
deleted file mode 100644
index 2003856..0000000
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ /dev/null
@@ -1,220 +0,0 @@
-<script lang="ts">
-import { loadSidebarPanels, saveSidebarPanels } from "../sidebar-storage.js";
-import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js";
-import CacheRatePanel from "./CacheRatePanel.svelte";
-import ClaudeReset from "./ClaudeReset.svelte";
-import ConfigPanel from "./ConfigPanel.svelte";
-import ContextWindowPanel from "./ContextWindowPanel.svelte";
-import DebugPanel from "./DebugPanel.svelte";
-import KeyUsage from "./KeyUsage.svelte";
-import ModelSelector from "./ModelSelector.svelte";
-import ModelStatus from "./ModelStatus.svelte";
-import SettingsPanel from "./SettingsPanel.svelte";
-import SkillsBrowser from "./SkillsBrowser.svelte";
-import TaskListPanel from "./TaskListPanel.svelte";
-import ToolPermissions from "./ToolPermissions.svelte";
-
-interface AgentInfo {
- slug: string;
- scope: string;
- skills: string[];
- tools: string[];
- models: Array<{ key_id: string; model_id: string }>;
- cwd?: string;
-}
-
-const {
- keys = [],
- tasks = [],
- cacheStats = null,
- cacheTabTitle = null,
- contextLimit = null,
- permissionLog = [],
- apiBase = "",
- activeTabId = null as string | null,
- activeKeyId = null,
- activeModelId = null,
- reasoningEffort = "max",
- activeAgentSlug = null as string | null,
- activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
- workingDirectory = null as string | null,
- onKeyChange,
- onModelChange,
- onReasoningChange,
- onAgentChange = (_agent: AgentInfo | null) => {},
- onWorkingDirectoryChange = (_dir: string | null) => {},
- onCompact = () => {},
- canCompact = false,
- compacting = false,
- onAddKey = () => {},
-}: {
- keys?: KeyInfo[];
- tasks?: TaskItem[];
- cacheStats?: CacheStats | null;
- cacheTabTitle?: string | null;
- contextLimit?: number | null;
- permissionLog?: LogEntry[];
- apiBase?: string;
- activeTabId?: string | null;
- activeKeyId?: string | null;
- activeModelId?: string | null;
- reasoningEffort?: string;
- activeAgentSlug?: string | null;
- activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
- workingDirectory?: string | null;
- onKeyChange: (keyId: string) => void;
- onModelChange: (keyId: string, modelId: string) => void;
- onReasoningChange: (effort: string) => void;
- onAgentChange?: (agent: AgentInfo | null) => void;
- onWorkingDirectoryChange?: (dir: string | null) => void;
- onCompact?: () => void;
- canCompact?: boolean;
- compacting?: boolean;
- onAddKey?: () => void;
-} = $props();
-
-interface Panel {
- id: number;
- selected: string;
-}
-
-// The `id` field is purely a stable key for Svelte's `{#each ... (panel.id)}`
-// block within a single session — it is NEVER persisted. Only the ordered
-// list of `selected` strings is round-tripped through localStorage; ids are
-// regenerated fresh from `nextId` on every mount.
-let nextId = 0;
-let panels = $state<Panel[]>(loadSidebarPanels().map((selected) => ({ id: nextId++, selected })));
-
-// Persist the layout whenever it changes. `$effect` re-runs whenever any
-// reactive read inside it changes; we read `panels` (the whole array) via
-// `.map`, which Svelte 5 tracks. Save errors are swallowed inside
-// `saveSidebarPanels` — best-effort.
-$effect(() => {
- saveSidebarPanels(panels.map((p) => p.selected));
-});
-
-const viewOptions = [
- "Select a view",
- "Chat Settings",
- "Key Usage",
- "Cache Rate",
- "Context Window",
- "Claude Reset",
- "Model Status",
- "Tasks",
- "Config",
- "Skills",
- "Tools",
- "Settings",
- "Debug",
-];
-
-function addPanel() {
- panels = [...panels, { id: nextId++, selected: "Select a view" }];
-}
-
-// Every panel sizes to its content; the sidebar itself (in App.svelte) is the
-// scroll container. We deliberately do NOT use `flex-1` fill here: a filled
-// panel combined with `min-h-0` lets flex shrink the panel below its content's
-// natural height, and since the content wrapper is a plain block the inner
-// scroll regions never receive a bounded height — so their bars/lists spill
-// out of the panel into neighbours or past the window edge.
-function panelClass(_selected: string): string {
- return "bg-base-200 rounded-lg p-3 flex flex-col";
-}
-
-function contentClass(_selected: string): string {
- return "mt-2";
-}
-</script>
-
-<div class="flex flex-col gap-2 min-h-0">
- {#each panels as panel, idx (panel.id)}
- <div class={panelClass(panel.selected)}>
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- value={panel.selected}
- onchange={(e) => {
- panels = panels.map((p) =>
- p.id === panel.id ? { ...p, selected: e.currentTarget.value } : p,
- );
- }}
- >
- {#each viewOptions as option}
- <option value={option} disabled={option === "Select a view"}>{option}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-sm btn-ghost btn-square shrink-0"
- aria-label="Remove panel"
- onclick={() => {
- panels = panels.filter((p) => p.id !== panel.id);
- }}
- >
- ✕
- </button>
- {/if}
- </div>
-
- <div class={contentClass(panel.selected)}>
- {#if panel.selected === "Chat Settings"}
- <ModelSelector
- {keys}
- {activeTabId}
- {activeKeyId}
- {activeModelId}
- {reasoningEffort}
- {onKeyChange}
- {onModelChange}
- {onReasoningChange}
- {activeAgentSlug}
- {activeTabParentId}
- {activeAgentModels}
- {onAgentChange}
- {workingDirectory}
- {onWorkingDirectoryChange}
- {onCompact}
- {canCompact}
- {compacting}
- />
- {:else if panel.selected === "Key Usage"}
- <KeyUsage {keys} {apiBase} />
- {:else if panel.selected === "Cache Rate"}
- <CacheRatePanel {cacheStats} tabTitle={cacheTabTitle} />
- {:else if panel.selected === "Context Window"}
- <ContextWindowPanel
- {cacheStats}
- {contextLimit}
- tabTitle={cacheTabTitle}
- modelId={activeModelId}
- />
- {:else if panel.selected === "Claude Reset"}
- <ClaudeReset {apiBase} />
- {:else if panel.selected === "Model Status"}
- <ModelStatus {keys} {apiBase} {onAddKey} />
- {:else if panel.selected === "Tasks"}
- <TaskListPanel {tasks} />
- {:else if panel.selected === "Config"}
- <ConfigPanel {apiBase} />
- {:else if panel.selected === "Skills"}
- <SkillsBrowser {apiBase} />
- {:else if panel.selected === "Tools"}
- <ToolPermissions entries={permissionLog} {apiBase} />
- {:else if panel.selected === "Settings"}
- <SettingsPanel {keys} {apiBase} />
- {:else if panel.selected === "Debug"}
- <DebugPanel />
- {/if}
- </div>
- </div>
- {/each}
-
- <button type="button" class="btn bg-base-200 hover:bg-base-300 border-none w-full text-lg" onclick={addPanel}>
- +
- </button>
-</div>
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte
deleted file mode 100644
index e697732..0000000
--- a/packages/frontend/src/lib/components/SkillsBrowser.svelte
+++ /dev/null
@@ -1,317 +0,0 @@
-<script lang="ts">
-import { appSettings } from "../settings.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-
-interface Skill {
- name: string;
- description: string;
- tags: string[];
- scope: "global" | "project";
- directory: string;
-}
-
-interface SkillsResponse {
- skills: Skill[];
- mappings: unknown[];
-}
-
-interface SkillDetail extends Skill {
- content: string;
- source: string;
-}
-
-interface DirGroup {
- path: string;
- label: string;
- scope: "global" | "project";
- skills: Skill[];
-}
-
-const {
- apiBase,
- checkedSkills = null,
- onSkillToggle = null,
-}: {
- apiBase: string;
- /** External checked set (agent builder mode). When null, uses appSettings. */
- checkedSkills?: Set<string> | null;
- /** Callback when a skill is toggled in external mode. */
- onSkillToggle?: ((key: string, checked: boolean) => void) | null;
-} = $props();
-
-/** Whether we're in external (agent builder) mode */
-const externalMode = $derived(checkedSkills !== null && onSkillToggle !== null);
-
-let skills = $state<Skill[]>([]);
-let loading = $state(false);
-let error = $state<string | null>(null);
-let expandedSkill = $state<string | null>(null);
-let expandedDetail = $state<SkillDetail | null>(null);
-let loadingDetail = $state(false);
-let collapsedDirs = $state<Set<string>>(new Set());
-
-async function fetchSkills() {
- loading = true;
- error = null;
- try {
- const res = await fetch(`${apiBase}/skills`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data: SkillsResponse = await res.json();
- skills = data.skills ?? [];
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch skills";
- } finally {
- loading = false;
- }
-}
-
-function skillKey(skill: Skill): string {
- return `${skill.scope}:${skill.name}`;
-}
-
-/** Build a unique key for a directory group (scope + path) */
-function dirKey(group: DirGroup): string {
- return `${group.scope}:${group.path}`;
-}
-
-function isChecked(skill: Skill): boolean {
- const key = skillKey(skill);
- if (externalMode) {
- return checkedSkills?.has(key) ?? false;
- }
- return appSettings.skillChecks[key] === true;
-}
-
-function isInjected(skill: Skill): boolean {
- if (externalMode) return false;
- return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false;
-}
-
-function toggleCheck(skill: Skill): void {
- const key = skillKey(skill);
- if (externalMode) {
- onSkillToggle?.(key, !checkedSkills?.has(key));
- return;
- }
- appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) };
-}
-
-function resetChecks(): void {
- if (externalMode) return;
- appSettings.skillChecks = {};
-}
-
-function toggleDir(key: string): void {
- const next = new Set(collapsedDirs);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- collapsedDirs = next;
-}
-
-/** Check if a group is hidden because an ancestor directory is collapsed */
-function isHiddenByParent(group: DirGroup): boolean {
- if (!group.path.includes("/")) return false;
- // Check each ancestor path segment
- const parts = group.path.split("/");
- for (let i = 1; i < parts.length; i++) {
- const ancestorPath = parts.slice(0, i).join("/");
- const ancestorKey = `${group.scope}:${ancestorPath}`;
- if (collapsedDirs.has(ancestorKey)) return true;
- }
- return false;
-}
-
-/** Get the top-level directory for grouping spacing */
-function topLevelDir(group: DirGroup): string {
- const slash = group.path.indexOf("/");
- return slash === -1 ? group.path : group.path.slice(0, slash);
-}
-
-async function toggleExpand(skill: Skill) {
- const key = skillKey(skill);
- if (expandedSkill === key) {
- expandedSkill = null;
- expandedDetail = null;
- return;
- }
- expandedSkill = key;
- expandedDetail = null;
- loadingDetail = true;
- try {
- const res = await fetch(
- `${apiBase}/skills/${encodeURIComponent(skill.name)}?scope=${skill.scope}`,
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- expandedDetail = await res.json();
- } catch {
- expandedDetail = null;
- } finally {
- loadingDetail = false;
- }
-}
-
-$effect(() => {
- fetchSkills();
-});
-
-const checkedCount = $derived(
- externalMode
- ? (checkedSkills?.size ?? 0)
- : Object.values(appSettings.skillChecks).filter((v) => v).length,
-);
-
-/** Group skills by scope + directory, sorted */
-const dirGroups = $derived.by((): DirGroup[] => {
- const map = new Map<string, DirGroup>();
- for (const skill of skills) {
- const key = `${skill.scope}:${skill.directory}`;
- let group = map.get(key);
- if (!group) {
- const label = skill.directory || "(root)";
- group = { path: skill.directory, label, scope: skill.scope, skills: [] };
- map.set(key, group);
- }
- group.skills.push(skill);
- }
- // Sort: global before project, then alphabetically by path
- const groups = Array.from(map.values());
- groups.sort((a, b) => {
- if (a.scope !== b.scope) return a.scope === "global" ? -1 : 1;
- return a.path.localeCompare(b.path);
- });
- // Sort skills within each group alphabetically
- for (const g of groups) {
- g.skills.sort((a, b) => a.name.localeCompare(b.name));
- }
- return groups;
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="flex items-center gap-2">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Skills</div>
- {#if !loading}
- <span class="badge badge-sm badge-neutral">{skills.length}</span>
- {/if}
- {#if checkedCount > 0}
- <span class="badge badge-sm badge-primary">{checkedCount} {externalMode ? 'selected' : 'queued'}</span>
- {/if}
- <button
- class="btn btn-xs btn-ghost ml-auto"
- onclick={fetchSkills}
- title="Refresh skills"
- >
- Refresh
- </button>
- </div>
-
- {#if !externalMode}
- <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p>
- {/if}
-
- {#if loading}
- <div class="flex items-center gap-2 py-2 text-base-content/60">
- <span class="loading loading-spinner loading-xs"></span>
- Loading skills...
- </div>
- {:else if error}
- <div class="alert alert-error text-xs py-2">{error}</div>
- {:else if skills.length === 0}
- <p class="text-base-content/50 italic py-2">
- No skills found. Create <code class="font-mono">.skills/</code> directories to get started.
- </p>
- {:else}
- <div class="flex flex-col">
- {#each dirGroups as group, idx (dirKey(group))}
- {@const collapsed = collapsedDirs.has(dirKey(group))}
- {@const hidden = isHiddenByParent(group)}
- {@const prevGroup = dirGroups[idx - 1]}
- {@const isNewTopLevel = idx === 0 || !prevGroup || topLevelDir(group) !== topLevelDir(prevGroup) || group.scope !== prevGroup.scope}
- {#if !hidden}
- {#if isNewTopLevel && idx > 0}
- <div class="divider my-1"></div>
- {/if}
- <div class="{isNewTopLevel ? '' : 'mt-0.5'}">
- <div class="rounded border border-base-content/20">
- <!-- Directory header -->
- <button
- type="button"
- class="flex items-center gap-1.5 w-full px-2 py-1.5 text-left hover:bg-base-200 transition-colors rounded-t"
- onclick={() => toggleDir(dirKey(group))}
- >
- <span class="text-xs text-base-content/40 w-3 inline-block transition-transform {collapsed ? '-rotate-90' : ''}">▼</span>
- <span class="font-mono text-xs font-semibold text-base-content/70">{group.label}</span>
- <span class="badge badge-xs {group.scope === 'global' ? 'badge-info' : 'badge-warning'}">{group.scope}</span>
- <span class="badge badge-xs badge-neutral ml-auto">{group.skills.length}</span>
- </button>
-
- <!-- Skills in this directory -->
- {#if !collapsed}
- <div class="flex flex-col gap-0.5 px-1 pb-1">
- {#each group.skills as skill (skillKey(skill))}
- {@const key = skillKey(skill)}
- {@const checked = isChecked(skill)}
- {@const injected = isInjected(skill)}
- <div
- class="rounded p-1.5 transition-colors {injected ? 'bg-primary/10 border border-primary/20' : 'hover:bg-base-200'}"
- >
- <label class="flex items-start gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm checkbox-primary rounded-sm mt-0.5"
- checked={checked}
- onchange={() => toggleCheck(skill)}
- />
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-1.5 flex-wrap">
- <button
- class="font-mono text-xs text-left hover:underline {injected ? 'text-primary font-semibold' : 'text-base-content'}"
- onclick={() => toggleExpand(skill)}
- >
- {skill.name}
- </button>
- {#if injected}
- <span class="badge badge-xs badge-primary">active</span>
- {/if}
- {#each skill.tags as tag}
- <span class="badge badge-xs badge-outline">{tag}</span>
- {/each}
- </div>
- {#if skill.description}
- <p class="text-xs text-base-content/50 truncate">{skill.description}</p>
- {/if}
- </div>
- </label>
-
- {#if expandedSkill === key}
- <div class="mt-2 ml-6 bg-base-300 rounded p-2">
- {#if loadingDetail}
- <span class="loading loading-spinner loading-xs text-base-content/40"></span>
- {:else if expandedDetail}
- <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{expandedDetail.content}</pre>
- {:else}
- <p class="text-error text-xs">Failed to load skill content.</p>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
- </div>
- </div>
- {/if}
- {/each}
- </div>
- {/if}
-
- {#if !externalMode}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.skillChecksDirty}
- onclick={resetChecks}
- >
- Reset
- </button>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte
deleted file mode 100644
index d9039f4..0000000
--- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte
+++ /dev/null
@@ -1,61 +0,0 @@
-<script lang="ts">
-import { onMount } from "svelte";
-import { appSettings } from "../settings.svelte.js";
-
-const {
- apiBase = "",
-}: {
- apiBase?: string;
-} = $props();
-
-const DEFAULT_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful.";
-
-async function loadPrompt(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/system_prompt`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- const value = data.value ?? DEFAULT_PROMPT;
- appSettings.systemPrompt = value;
- appSettings.savedSystemPrompt = value;
- }
- } catch {
- // ignore
- }
-}
-
-function resetPrompt(): void {
- appSettings.systemPrompt = appSettings.savedSystemPrompt;
-}
-
-const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
-
-onMount(() => {
- if (!appSettings.systemPrompt) {
- appSettings.systemPrompt = DEFAULT_PROMPT;
- appSettings.savedSystemPrompt = DEFAULT_PROMPT;
- }
- loadPrompt();
-});
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">System Prompt</div>
- <p class="text-xs text-base-content/40">The base instructions sent to the AI at the start of every conversation. Tool descriptions are appended automatically. Changes are applied when you send your next message.</p>
-
- <textarea
- class="textarea textarea-bordered w-full flex-1 min-h-32 text-xs font-mono leading-relaxed"
- bind:value={appSettings.systemPrompt}
- placeholder="Enter system prompt..."
- ></textarea>
-
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!isDirty}
- onclick={resetPrompt}
- >
- Reset
- </button>
-
- <p class="text-xs text-base-content/40">Warning: changing the system prompt will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
-</div>
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
deleted file mode 100644
index 7371f7b..0000000
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ /dev/null
@@ -1,234 +0,0 @@
-<script lang="ts">
-import { tick } from "svelte";
-import type { Tab } from "../tabs.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-
-function statusColor(status: string): string {
- if (status === "running") return "bg-warning";
- if (status === "error") return "bg-error";
- return "bg-success";
-}
-
-/**
- * A tab "needs attention" — and should ping to grab the user's eye — when the
- * agent has stopped and is likely waiting on the user:
- * (a) the turn ended (idle) but the task list still has incomplete tasks
- * (pending / in_progress) — the agent probably expects a response; or
- * (b) the turn stopped due to an error of any kind.
- */
-function needsAttention(tab: Tab): boolean {
- if (tab.agentStatus === "error") return true;
- if (tab.agentStatus === "idle") {
- return tab.tasks.some((t) => t.status === "pending" || t.status === "in_progress");
- }
- return false;
-}
-
-const userTabs = $derived(tabStore.tabs.filter((t) => t.parentTabId === null));
-const subagentTabs = $derived(
- tabStore.tabs.filter((t) => t.parentTabId !== null && t.parentTabId === activeUserTabId),
-);
-const hasSubagentTabs = $derived(subagentTabs.length > 0);
-
-// When a subagent tab is active, its parent user tab should still appear selected
-const activeTab = $derived(tabStore.tabs.find((t) => t.id === tabStore.activeTabId));
-const activeUserTabId = $derived(
- activeTab?.parentTabId !== null && activeTab?.parentTabId !== undefined
- ? activeTab.parentTabId
- : tabStore.activeTabId,
-);
-
-// ── Drag-and-drop reorder (user tabs only) ──
-// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte.
-let dragIndex = $state<number | null>(null);
-let dragOverIndex = $state<number | null>(null);
-
-function dropReorder(targetIndex: number): void {
- if (dragIndex !== null && dragIndex !== targetIndex) {
- const ids = userTabs.map((t) => t.id);
- const moved = ids.splice(dragIndex, 1)[0];
- if (moved) {
- ids.splice(targetIndex, 0, moved);
- tabStore.reorderTabs(ids);
- }
- }
- dragIndex = null;
- dragOverIndex = null;
-}
-
-// ── Double-click rename (user tabs only) ──
-let editingTabId = $state<string | null>(null);
-let editValue = $state("");
-let editInputEl = $state<HTMLInputElement | undefined>(undefined);
-
-async function startRename(tab: { id: string; title: string }): Promise<void> {
- editingTabId = tab.id;
- editValue = tab.title;
- await tick();
- editInputEl?.focus();
- editInputEl?.select();
-}
-
-function commitRename(): void {
- if (editingTabId === null) return;
- const id = editingTabId;
- editingTabId = null;
- const next = editValue.trim();
- if (next) tabStore.renameTab(id, next);
-}
-
-function cancelRename(): void {
- editingTabId = null;
-}
-
-function handleRenameKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
-}
-</script>
-
-<!-- Top row: user tabs -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div
- class="overflow-x-auto bg-base-200 flex-shrink-0 {hasSubagentTabs ? '' : 'rounded-br-lg'}"
- ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tablist"
- tabindex="0"
- class="tabs tabs-lift min-w-max"
- ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
- >
- <!-- New tab button — sticky-pinned to the left edge so it stays reachable
- at any horizontal scroll; opaque bg + right-side shadow as a floating cue. -->
- <button
- type="button"
- class="tab tab-active !sticky left-0 z-10 !rounded-ss-none !border-l-0 shadow-[2px_0_4px_-1px_rgba(0,0,0,0.2)]"
- onclick={() => tabStore.createNewTab()}
- aria-label="New tab"
- >
- +
- </button>
-
- {#each userTabs as tab, i (tab.id)}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tab"
- class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}"
- draggable={editingTabId === tab.id ? "false" : "true"}
- onclick={() => tabStore.switchTab(tab.id)}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }}
- ondragstart={(e) => {
- dragIndex = i;
- if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
- }}
- ondragover={(e) => {
- e.preventDefault();
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
- dragOverIndex = i;
- }}
- ondragleave={() => { if (dragOverIndex === i) dragOverIndex = null; }}
- ondrop={(e) => { e.preventDefault(); dropReorder(i); }}
- ondragend={() => { dragIndex = null; dragOverIndex = null; }}
- tabindex="0"
- >
- <span class="flex items-center gap-1.5">
- {#if needsAttention(tab)}
- <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
- <span class="w-1.5 h-1.5 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
- <span class="w-1.5 h-1.5 rounded-full {statusColor(tab.agentStatus)}"></span>
- </span>
- {:else}
- <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
- {/if}
- <span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- {#if editingTabId === tab.id}
- <input
- bind:this={editInputEl}
- bind:value={editValue}
- class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40"
- onclick={(e) => e.stopPropagation()}
- ondblclick={(e) => e.stopPropagation()}
- onkeydown={handleRenameKeydown}
- onblur={commitRename}
- />
- {:else}
- <span
- class="max-w-32 truncate text-xs"
- ondblclick={(e) => { e.stopPropagation(); startRename(tab); }}
- title="Double-click to rename"
- >{tab.title}</span>
- {/if}
- </span>
- <button
- type="button"
- class="flex items-center justify-center px-3 my-1 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs"
- onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }}
- aria-label="Close tab"
- >
- &#x2715;
- </button>
- </div>
- {/each}
-
- <!-- Trailing padding after the last tab. Fills remaining space (big target),
- shrinks to a small minimum when the bar overflows and scrolls.
- Double-click anywhere in it to open a new tab. -->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="flex-1 min-w-12 self-stretch cursor-default"
- ondblclick={() => tabStore.createNewTab()}
- title="Double-click to open a new tab"
- ></div>
- </div>
-</div>
-
-<!-- Bottom row: subagent tabs (hidden when empty) -->
-{#if hasSubagentTabs}
- <div class="overflow-x-auto bg-base-200 flex-shrink-0 border-t border-base-300 rounded-br-lg">
- <div
- role="tablist"
- class="tabs tabs-lift tabs-xs min-w-max"
- >
- {#each subagentTabs as tab (tab.id)}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tab"
- class="tab !flex items-stretch gap-1 {tab.id === tabStore.activeTabId ? 'tab-active' : ''} {!tab.persistent ? 'opacity-70 italic' : ''}"
- onclick={() => tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id)}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id); }}
- tabindex="0"
- >
- <span class="flex items-center gap-1">
- {#if needsAttention(tab)}
- <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
- <span class="w-1 h-1 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
- <span class="w-1 h-1 rounded-full {statusColor(tab.agentStatus)}"></span>
- </span>
- {:else}
- <span class="w-1 h-1 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
- {/if}
- <span class="font-mono text-[10px] px-1 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- <span class="max-w-28 truncate text-xs">{tab.title}</span>
- </span>
- {#if tab.persistent}
- <button
- type="button"
- class="flex items-center justify-center px-2 my-0.5 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs"
- onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }}
- aria-label="Close tab"
- >
- &#x2715;
- </button>
- {/if}
- </div>
- {/each}
- </div>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte
deleted file mode 100644
index 1f84bb8..0000000
--- a/packages/frontend/src/lib/components/TaskListPanel.svelte
+++ /dev/null
@@ -1,80 +0,0 @@
-<script lang="ts">
-import type { TaskItem } from "../types.js";
-
-const { tasks }: { tasks: TaskItem[] } = $props();
-
-type Status = TaskItem["status"];
-
-const completedCount = $derived(tasks.filter((t) => t.status === "completed").length);
-const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length);
-const cancelledCount = $derived(tasks.filter((t) => t.status === "cancelled").length);
-// "Active" total excludes cancelled items, so progress reads as work that still counts.
-const activeTotal = $derived(tasks.length - cancelledCount);
-
-function checkboxClass(status: Status): string {
- switch (status) {
- case "pending":
- return "checkbox checkbox-sm rounded-sm checkbox-secondary";
- case "in_progress":
- return "checkbox checkbox-sm rounded-sm checkbox-info";
- case "completed":
- return "checkbox checkbox-sm rounded-sm checkbox-success";
- case "cancelled":
- return "checkbox checkbox-sm rounded-sm checkbox-neutral";
- }
-}
-
-function isChecked(status: Status): boolean {
- return status === "completed";
-}
-
-function isIndeterminate(status: Status): boolean {
- return status === "in_progress";
-}
-
-function rowClass(status: Status): string {
- if (status === "completed") return "opacity-60";
- if (status === "cancelled") return "opacity-40";
- return "";
-}
-
-function textClass(status: Status): string {
- switch (status) {
- case "completed":
- return "line-through text-base-content/50";
- case "cancelled":
- return "line-through text-base-content/40";
- case "in_progress":
- return "font-semibold";
- default:
- return "";
- }
-}
-</script>
-
-<div class="flex flex-col gap-2">
- {#if tasks.length === 0}
- <p class="text-xs text-base-content/50">No tasks yet.</p>
- {:else}
- <p class="text-xs text-base-content/60">
- {completedCount}/{activeTotal} completed{#if inProgressCount > 0}, {inProgressCount} in progress{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
- </p>
- <ul class="flex flex-col gap-0.5">
- {#each tasks as task (task.id)}
- <li class="flex items-start gap-2 rounded p-1.5 transition-colors {rowClass(task.status)}">
- <input
- type="checkbox"
- class={checkboxClass(task.status)}
- checked={isChecked(task.status)}
- indeterminate={isIndeterminate(task.status)}
- disabled
- tabindex="-1"
- />
- <span class="text-xs leading-tight min-w-0 {textClass(task.status)}">
- {task.content}
- </span>
- </li>
- {/each}
- </ul>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
deleted file mode 100644
index 1b4ebca..0000000
--- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte
+++ /dev/null
@@ -1,140 +0,0 @@
-<script lang="ts">
-import { tabStore } from "../tabs.svelte.js";
-import type { ToolBatchEntry } from "../types.js";
-
-const { toolCall }: { toolCall: ToolBatchEntry } = $props();
-
-let isExpanded = $state(false);
-
-function toggle() {
- isExpanded = !isExpanded;
-}
-
-interface ShellResult {
- stdout: string;
- stderr: string;
- exitCode: number;
-}
-
-function parseShellResult(result: string): ShellResult | null {
- try {
- const parsed = JSON.parse(result) as unknown;
- if (
- parsed !== null &&
- typeof parsed === "object" &&
- "stdout" in parsed &&
- "stderr" in parsed &&
- "exitCode" in parsed
- ) {
- return {
- stdout: String((parsed as Record<string, unknown>).stdout ?? ""),
- stderr: String((parsed as Record<string, unknown>).stderr ?? ""),
- exitCode: Number((parsed as Record<string, unknown>).exitCode ?? 0),
- };
- }
- return null;
- } catch {
- return null;
- }
-}
-
-const isShell = $derived(toolCall.name === "run_shell");
-const shellResult = $derived(
- isShell && toolCall.result !== undefined ? parseShellResult(toolCall.result) : null,
-);
-
-const summonAgentId = $derived.by(() => {
- if (toolCall.name !== "summon" || !toolCall.result) return null;
- const match = toolCall.result.match(/agent_id:\s*([a-f0-9-]+)/);
- return match ? match[1] : null;
-});
-</script>
-
-<div class="collapse collapse-arrow mb-2 p-1 opacity-60 {isExpanded ? 'collapse-open' : ''}">
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="collapse-title flex items-center gap-2 text-sm italic cursor-pointer w-full text-left"
- onclick={toggle}
- role="button"
- tabindex="0"
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggle(); }}
- aria-expanded={isExpanded}
- >
- <span class="badge badge-neutral badge-sm">tool</span>
- <span class="font-mono">{toolCall.name}</span>
- {#if summonAgentId !== null}
- <button
- type="button"
- class="btn btn-xs btn-ghost"
- onclick={(e) => { e.stopPropagation(); tabStore.openAgentTab(summonAgentId!); }}
- >Open Tab</button>
- {/if}
- {#if toolCall.result !== undefined}
- {#if toolCall.result.includes("[USER INTERRUPT]")}
- <span class="badge badge-info badge-sm ml-auto">interrupted</span>
- {:else if isShell && shellResult !== null}
- <span class="badge badge-sm ml-auto {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">
- exit {shellResult.exitCode}
- </span>
- {:else if toolCall.isError}
- <span class="badge badge-error badge-sm ml-auto">error</span>
- {:else}
- <span class="badge badge-success badge-sm ml-auto">done</span>
- {/if}
- {:else}
- <span class="badge badge-warning badge-sm ml-auto">pending</span>
- {/if}
- </div>
-
- <div class="collapse-content text-xs">
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Arguments</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
- </div>
- {#if isShell && toolCall.result !== undefined}
- {#if shellResult !== null}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout:</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stdout || "(empty)"}</pre>
- </div>
- {#if shellResult.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/80 mb-1">stderr:</p>
- <pre class="bg-error/10 text-error rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stderr}</pre>
- </div>
- {/if}
- <div class="mt-2 flex items-center gap-2">
- <span class="font-semibold text-base-content/70">exit code:</span>
- <span class="badge badge-sm {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">{shellResult.exitCode}</span>
- </div>
- {:else}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Result</p>
- <pre class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError ? 'bg-error/20 text-error' : 'bg-base-300'}">{toolCall.result}</pre>
- </div>
- {/if}
- {:else if isShell && toolCall.shellOutput}
- {#if toolCall.shellOutput.stdout}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs">{toolCall.shellOutput.stdout}</pre>
- </div>
- {/if}
- {#if toolCall.shellOutput.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/70 mb-1">stderr</p>
- <pre class="bg-error/10 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs text-error">{toolCall.shellOutput.stderr}</pre>
- </div>
- {/if}
- <span class="text-xs text-base-content/50 italic">Running...</span>
- {:else if toolCall.result !== undefined}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Result</p>
- <pre
- class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError
- ? 'bg-error/20 text-error'
- : 'bg-base-300'}">{toolCall.result}</pre>
- </div>
- {/if}
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
deleted file mode 100644
index 4298724..0000000
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ /dev/null
@@ -1,185 +0,0 @@
-<script lang="ts">
-import { onMount } from "svelte";
-import { appSettings } from "../settings.svelte.js";
-import type { LogEntry } from "../types.js";
-
-interface ToolPermission {
- id: string;
- label: string;
- description: string;
-}
-
-const toolPermissions: ToolPermission[] = [
- { id: "read", label: "Read files", description: "Allow the AI to read files in the workspace" },
- {
- id: "edit",
- label: "Edit files",
- description: "Allow the AI to write/edit files in the workspace",
- },
- { id: "bash", label: "Run commands", description: "Allow the AI to execute shell commands" },
- {
- id: "summon",
- label: "Summon agents",
- description: "Allow the AI to spawn child agents to work on tasks",
- },
- {
- id: "user_agent",
- label: "Spawn user agents",
- description: "Allow the AI to open new independent top-level tabs",
- },
- {
- id: "send_to_tab",
- label: "Message other tabs",
- description: "Allow the AI to send messages to other tabs by their ID",
- },
- {
- id: "read_tab",
- label: "Read other tabs",
- description: "Allow the AI to read other tabs' latest responses by their ID",
- },
- {
- id: "web_search",
- label: "Web search",
- description: "Allow the AI to search the web via Firecrawl",
- },
- {
- id: "youtube_transcribe",
- label: "YouTube transcripts",
- description: "Allow the AI to fetch YouTube video transcripts",
- },
- {
- id: "search_code",
- label: "Search code",
- description: "Allow the AI to search the codebase with the cs ranked code-search engine",
- },
- {
- id: "key_usage",
- label: "Key usage",
- description:
- "Allow the AI to read current API-key usage levels, rate-limit headroom, and reset times",
- },
- {
- id: "lsp",
- label: "LSP queries",
- description:
- "Allow the AI to query a language server for hover, go-to-definition, references, and more",
- },
-];
-
-const {
- entries = [],
- apiBase = "",
- checkedTools = null,
- onToolToggle = null,
-}: {
- entries?: LogEntry[];
- apiBase?: string;
- /** External checked set (agent builder mode). When null, uses appSettings. */
- checkedTools?: Set<string> | null;
- /** Callback when a tool is toggled in external mode. */
- onToolToggle?: ((id: string, checked: boolean) => void) | null;
-} = $props();
-
-/** Whether we're in external (agent builder) mode */
-const externalMode = $derived(checkedTools !== null && onToolToggle !== null);
-
-function isChecked(id: string): boolean {
- if (externalMode) return checkedTools?.has(id) ?? false;
- return appSettings.toolPerms[id] === true;
-}
-
-async function loadPermissions(): Promise<void> {
- const loaded: Record<string, boolean> = { ...appSettings.toolPerms };
- for (const perm of toolPermissions) {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/perm_${perm.id}`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- if (data.value !== null) {
- loaded[perm.id] = data.value === "allow";
- }
- }
- } catch {
- // ignore
- }
- }
- appSettings.toolPerms = { ...loaded };
- appSettings.savedToolPerms = { ...loaded };
-}
-
-function togglePermission(id: string): void {
- if (externalMode) {
- onToolToggle?.(id, !checkedTools?.has(id));
- return;
- }
- appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] };
-}
-
-function resetPermissions(): void {
- appSettings.toolPerms = { ...appSettings.savedToolPerms };
-}
-
-onMount(() => {
- if (!externalMode) {
- loadPermissions();
- }
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Tool Permissions</div>
- {#if !externalMode}
- <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
- {/if}
-
- <div class="flex flex-col gap-1.5">
- {#each toolPermissions as perm (perm.id)}
- <label class="flex items-start gap-2 cursor-pointer p-1 rounded hover:bg-base-200 transition-colors">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm mt-0.5"
- checked={isChecked(perm.id)}
- onchange={() => togglePermission(perm.id)}
- />
- <div class="flex flex-col">
- <span class="text-xs font-medium text-base-content">{perm.label}</span>
- <span class="text-xs text-base-content/40">{perm.description}</span>
- </div>
- </label>
- {/each}
- </div>
-
- {#if !externalMode}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.toolPermsDirty}
- onclick={resetPermissions}
- >
- Reset
- </button>
-
- <p class="text-xs text-base-content/40">Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
-
- <!-- Permission Log -->
- {#if entries.length > 0}
- <div class="collapse collapse-arrow bg-base-200 mt-2">
- <input type="checkbox" />
- <div class="collapse-title text-sm font-medium py-2 min-h-0">
- Log ({entries.length})
- </div>
- <div class="collapse-content text-xs max-h-40 overflow-y-auto">
- {#each entries as entry (entry.id)}
- <div class="flex items-center gap-2 py-1 border-b border-base-300">
- <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}">
- {entry.action}
- </span>
- <span class="text-base-content/70">{entry.permission}</span>
- <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span>
- </div>
- <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p>
- {/each}
- </div>
- </div>
- {/if}
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts
deleted file mode 100644
index 0565367..0000000
--- a/packages/frontend/src/lib/config.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-const STORAGE_KEY = "dispatch-api-url";
-
-function getDefaultApiBase(): string {
- if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL;
- // Derive from current page hostname so it works over Tailscale/LAN
- if (typeof window !== "undefined" && window.location.hostname !== "localhost") {
- return `http://${window.location.hostname}:3000`;
- }
- return "http://localhost:3000";
-}
-
-const DEFAULT_API_BASE = getDefaultApiBase();
-
-function loadApiBase(): string {
- if (typeof localStorage !== "undefined") {
- const saved = localStorage.getItem(STORAGE_KEY);
- if (saved) return saved;
- }
- return DEFAULT_API_BASE;
-}
-
-let _apiBase = loadApiBase();
-
-export const config = {
- get apiBase() {
- return _apiBase;
- },
- get wsUrl() {
- return `${_apiBase.replace(/^http/, "ws")}/ws`;
- },
- get defaultApiBase() {
- return DEFAULT_API_BASE;
- },
- setApiBase(url: string) {
- _apiBase = url;
- if (typeof localStorage !== "undefined") {
- if (url === DEFAULT_API_BASE) {
- localStorage.removeItem(STORAGE_KEY);
- } else {
- localStorage.setItem(STORAGE_KEY, url);
- }
- }
- },
-};
diff --git a/packages/frontend/src/lib/context-window.ts b/packages/frontend/src/lib/context-window.ts
deleted file mode 100644
index c4321f8..0000000
--- a/packages/frontend/src/lib/context-window.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { CacheStats } from "./types.js";
-
-/**
- * Context-window occupancy for the current tab/model.
- *
- * `current` is the size of the model's context on the MOST RECENT request —
- * the last turn's full prompt (`inputTokens`, which already includes cached
- * tokens for Anthropic) plus what the model generated that turn
- * (`outputTokens`). This mirrors how opencode derives context fullness from
- * the last assistant message, and reflects what actually occupies the model's
- * window — NOT the session-cumulative totals shown by the Cache Rate view.
- *
- * `max` is the model's maximum context window from models.dev (or `null` when
- * unknown). `percent` is `current / max * 100` clamped to [0, 100] (unrounded;
- * the UI decides the displayed precision), or `null` when
- * `max` is unknown — in which case the UI shows the bare token count with no
- * denominator or progress bar.
- */
-export interface ContextUsage {
- current: number;
- max: number | null;
- percent: number | null;
-}
-
-export function computeContextUsage(
- cacheStats: CacheStats | null | undefined,
- contextLimit: number | null | undefined,
-): ContextUsage {
- const last = cacheStats?.last ?? null;
- const current = last ? last.inputTokens + last.outputTokens : 0;
- const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null;
- // Precise (unrounded) percentage clamped to [0, 100]; the UI formats the
- // decimal places. Kept unrounded so small contexts against huge windows
- // (e.g. a few thousand tokens vs. 1,000,000) still read non-zero.
- const percent = max ? Math.max(0, Math.min(100, (current / max) * 100)) : null;
- return { current, max, percent };
-}
diff --git a/packages/frontend/src/lib/router.svelte.ts b/packages/frontend/src/lib/router.svelte.ts
deleted file mode 100644
index 8bed880..0000000
--- a/packages/frontend/src/lib/router.svelte.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-type Page = "dashboard" | "agent-builder";
-
-let currentPage = $state<Page>("dashboard");
-
-export const router = {
- get page() {
- return currentPage;
- },
- navigate(page: Page) {
- currentPage = page;
- },
-};
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts
deleted file mode 100644
index 1b93804..0000000
--- a/packages/frontend/src/lib/settings.svelte.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/** Shared reactive app settings. */
-
-let autoExpandThinking = $state(false);
-let systemPrompt = $state("");
-let savedSystemPrompt = $state("");
-let toolPerms = $state<Record<string, boolean>>({
- read: true,
- edit: false,
- bash: false,
- summon: false,
- user_agent: false,
- send_to_tab: false,
- read_tab: false,
- external_directory: false,
- web_search: false,
- youtube_transcribe: false,
- search_code: false,
- key_usage: false,
- lsp: false,
-});
-let savedToolPerms = $state<Record<string, boolean>>({
- read: true,
- edit: false,
- bash: false,
- summon: false,
- user_agent: false,
- send_to_tab: false,
- read_tab: false,
- external_directory: false,
- web_search: false,
- youtube_transcribe: false,
- search_code: false,
- key_usage: false,
- lsp: false,
-});
-let skillChecks = $state<Record<string, boolean>>({});
-let chunkLimit = $state(100);
-
-export const appSettings = {
- get chunkLimit() {
- return chunkLimit;
- },
- set chunkLimit(v: number) {
- chunkLimit = v;
- },
- get autoExpandThinking() {
- return autoExpandThinking;
- },
- set autoExpandThinking(v: boolean) {
- autoExpandThinking = v;
- },
- get systemPrompt() {
- return systemPrompt;
- },
- set systemPrompt(v: string) {
- systemPrompt = v;
- },
- get savedSystemPrompt() {
- return savedSystemPrompt;
- },
- set savedSystemPrompt(v: string) {
- savedSystemPrompt = v;
- },
- get toolPerms() {
- return toolPerms;
- },
- set toolPerms(v: Record<string, boolean>) {
- toolPerms = v;
- },
- get savedToolPerms() {
- return savedToolPerms;
- },
- set savedToolPerms(v: Record<string, boolean>) {
- savedToolPerms = v;
- },
- get toolPermsDirty() {
- return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]);
- },
- get skillChecks() {
- return skillChecks;
- },
- set skillChecks(v: Record<string, boolean>) {
- skillChecks = v;
- },
- get skillChecksDirty() {
- return Object.values(skillChecks).some((v) => v);
- },
-};
diff --git a/packages/frontend/src/lib/sidebar-storage.ts b/packages/frontend/src/lib/sidebar-storage.ts
deleted file mode 100644
index 9d9928f..0000000
--- a/packages/frontend/src/lib/sidebar-storage.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * LocalStorage persistence for the sidebar panel layout.
- *
- * The sidebar (`SidebarPanel.svelte`) is a variable-length stack of
- * "slot" components; each slot has a dropdown that picks one of N view
- * components to render (Chat Settings, Tasks, Skills, etc.). The
- * source-of-truth `panels` array in the component records the order
- * and selection of each slot. This module persists that array's
- * `selected` field across browser refreshes/loads.
- *
- * Why localStorage and not the backend `settings` table:
- * - The sidebar layout is a UI preference, not domain state. It
- * matches the precedent set by `dispatch-theme` and
- * `dispatch-api-url`, both of which are localStorage.
- * - Per-device layout is reasonable (a phone may want different
- * panels than a desktop).
- * - No backend round-trip on every layout change.
- *
- * Why only the `selected` strings and not the full `Panel` objects:
- * - The `id` field is a session-ephemeral counter
- * (`SidebarPanel.svelte:61`) that exists only to keep Svelte's
- * `{#each ... (panel.id)}` block keyed across reorders. Restoring
- * ids verbatim would have no benefit, and would collide with the
- * module-scoped `nextId` counter on remount.
- * - The `selected` string is everything we need to reconstruct the
- * visible layout.
- */
-
-const LS_KEY = "dispatch-sidebar-panels";
-
-/**
- * The fallback layout when nothing is stored or the stored value is
- * unusable. Matches the hardcoded initial state at
- * `SidebarPanel.svelte:62` so first-ever load is unchanged: a single
- * "Chat Settings" panel.
- */
-const DEFAULT_LAYOUT: ReadonlyArray<string> = ["Chat Settings"];
-
-/**
- * Read the persisted sidebar layout. Returns an array of
- * `panel.selected` strings in render order (top-to-bottom).
- *
- * Falls back to `DEFAULT_LAYOUT` on any of:
- * - localStorage key absent (first-ever load)
- * - JSON.parse throws (corrupt write from a prior session)
- * - parsed value is not an array (someone hand-edited storage)
- * - parsed array, after filtering non-string entries, is empty
- * (preserves the "minimum one panel" invariant the UI enforces
- * via `{#if idx > 0}` on the remove button)
- *
- * Never throws.
- */
-export function loadSidebarPanels(): string[] {
- try {
- const raw = localStorage.getItem(LS_KEY);
- if (!raw) return [...DEFAULT_LAYOUT];
- const parsed: unknown = JSON.parse(raw);
- if (!Array.isArray(parsed)) return [...DEFAULT_LAYOUT];
- // Discard any non-string entries — they could only come from a
- // hand-edited localStorage or a future schema mismatch. Either
- // way, we don't want to render `undefined` as a panel name.
- const cleaned = parsed.filter((x): x is string => typeof x === "string");
- return cleaned.length > 0 ? cleaned : [...DEFAULT_LAYOUT];
- } catch {
- // localStorage access threw (SecurityError in restricted browser
- // contexts, etc.). Fall through to default.
- return [...DEFAULT_LAYOUT];
- }
-}
-
-/**
- * Persist the current sidebar layout. Best-effort: errors are
- * swallowed (quota exceeded, localStorage disabled in private mode,
- * SecurityError, etc.). The next save attempt may succeed; even if
- * none do, the current session continues to work — only the cross-
- * reload restore is degraded.
- */
-export function saveSidebarPanels(selected: string[]): void {
- try {
- localStorage.setItem(LS_KEY, JSON.stringify(selected));
- } catch {
- // Best-effort.
- }
-}
diff --git a/packages/frontend/src/lib/snapshot-sequencer.ts b/packages/frontend/src/lib/snapshot-sequencer.ts
deleted file mode 100644
index fccc9ef..0000000
--- a/packages/frontend/src/lib/snapshot-sequencer.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Tiny race guard for "the most-recent request wins" semantics.
- *
- * When a frontend component fans out multiple HTTP calls that each return a
- * full snapshot of shared state — and applying an older snapshot would clobber
- * a newer one — wrap each call with `seq = sequencer.begin()` before send and
- * `sequencer.accept(seq)` before applying the response. Older sequences are
- * rejected.
- *
- * Why: the Claude Wake Schedule's POST /toggle and GET /wake-schedule both
- * return the *whole* schedule. If a user toggles hour 9 (request A) and then
- * hour 10 (request B), and B's response arrives before A's, the older A
- * response — which doesn't know about hour 10 yet — would otherwise overwrite
- * hour 10 right out of the UI. A per-hour counter is NOT enough because the
- * race spans different hours (and also covers the initial-load vs first-click
- * race).
- *
- * `>=` on accept is intentional: if seq equals the latest applied seq, the
- * response is a redundant arrival of the most-recent winner — accepting it
- * (idempotently) is fine. The discriminator is *strictly less than*.
- */
-export class SnapshotSequencer {
- private nextSeq = 0;
- private latestApplied = 0;
-
- /** Tag a new request. Call before sending; pass the returned seq to accept(). */
- begin(): number {
- this.nextSeq += 1;
- return this.nextSeq;
- }
-
- /**
- * Decide whether to apply a response. Returns true if this seq is the
- * newest seen so far (and updates the watermark); false if a newer
- * response has already won.
- */
- accept(seq: number): boolean {
- if (seq < this.latestApplied) return false;
- this.latestApplied = seq;
- return true;
- }
-
- /** Inspect (for tests / debugging). */
- get state(): { nextSeq: number; latestApplied: number } {
- return { nextSeq: this.nextSeq, latestApplied: this.latestApplied };
- }
-}
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
deleted file mode 100644
index 16805df..0000000
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ /dev/null
@@ -1,2441 +0,0 @@
-// Import the chunk-builder helpers directly from core so the frontend store
-// and the backend agent share the exact same wire-format logic. Deep import
-// is intentional: the core barrel pulls in node-only deps (chokidar, etc.)
-// that don't belong in the browser bundle.
-import {
- appendEventToChunks,
- applySystemEvent,
- type IdentifiedMessage,
- type SystemEventLike,
-} from "@dispatch/core/src/chunks/append.js";
-// DB-free; safe in the browser bundle. The flat chunk log is the frontend's
-// source of truth for HISTORY; `groupRowsToMessages` derives render bubbles.
-import { groupRowsToMessages, type MessageRow } from "@dispatch/core/src/chunks/transform.js";
-import type { ChunkRow, UserContentPart } from "@dispatch/core/src/types/index.js";
-import {
- type AgentModelEntry,
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- type ReasoningEffort,
-} from "@dispatch/core/src/types/index.js";
-import { intactTokenIds, type StagedAttachment } from "./attachment-tokens.js";
-import { cacheWarming } from "./cache-warming.svelte.js";
-import { config } from "./config.js";
-import { appSettings } from "./settings.svelte.js";
-import type {
- AgentEvent,
- CacheStats,
- ChatMessage,
- Chunk,
- DebugInfo,
- LogEntry,
- PermissionPrompt,
- QueuedMessage,
- TabStatusSnapshot,
- TaskItem,
-} from "./types.js";
-import { wsClient } from "./ws.svelte.js";
-
-function generateId(): string {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- // Fallback for non-secure contexts (HTTP over Tailscale/LAN)
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
- const r = (Math.random() * 16) | 0;
- return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
- });
-}
-
-function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo {
- return {
- timestamp: new Date().toISOString(),
- connectionStatus: wsClient.connectionStatus,
- ...overrides,
- };
-}
-
-// ─── Chunk-log → render projection ───────────────────────────────
-//
-// History lives as a flat `ChunkRow[]` (sealed, real seq). For rendering we
-// group it into bubbles with `groupRowsToMessages` (pairs tool_call+tool_result
-// by callId, wraps a turn's assistant chunks) — a pure, ephemeral view, never
-// stored as the source of truth.
-
-/** Map a grouped chunk-row message to a render `ChatMessage`. */
-function rowGroupToMessage(m: MessageRow): ChatMessage {
- return {
- id: m.id,
- role: m.role,
- chunks: m.chunks,
- isStreaming: false,
- seq: m.seq,
- turnId: m.turnId,
- };
-}
-
-/**
- * The render view for a tab: grouped sealed chunks followed by the transient
- * live tail (current unsealed turn). This is what the chat panel renders.
- */
-function deriveRenderGroups(chunks: ChunkRow[], live: ChatMessage[]): ChatMessage[] {
- const sealed = groupRowsToMessages(chunks).map(rowGroupToMessage);
- return live.length > 0 ? [...sealed, ...live] : sealed;
-}
-
-/** Total chunk count of the live tail (for the eviction budget). */
-function countLiveChunks(live: ChatMessage[]): number {
- return live.reduce((sum, m) => sum + m.chunks.length, 0);
-}
-
-/** Smallest `seq` among sealed chunk rows, or null when empty. */
-function minSeqOf(chunks: ChunkRow[]): number | null {
- let min: number | null = null;
- for (const c of chunks) {
- if (typeof c.seq === "number" && (min === null || c.seq < min)) min = c.seq;
- }
- return min;
-}
-
-/** Merge older chunk rows into a window, dedupe by `seq`, keep ascending. */
-function mergeChunksBySeq(existing: ChunkRow[], incoming: ChunkRow[]): ChunkRow[] {
- const bySeq = new Map<number, ChunkRow>();
- for (const c of existing) bySeq.set(c.seq, c);
- for (const c of incoming) bySeq.set(c.seq, c);
- return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
-}
-
-/** Fetch a raw chunk window from the backend (the chunk-native load source). */
-async function fetchChunkWindow(
- tabId: string,
- params: { limit?: number; before?: number } = {},
-): Promise<{ ok: boolean; chunks: ChunkRow[]; total: number; oldestSeq: number | null }> {
- const qs = new URLSearchParams();
- if (params.limit !== undefined) qs.set("limit", String(params.limit));
- if (params.before !== undefined) qs.set("before", String(params.before));
- const q = qs.toString();
- try {
- const res = await fetch(`${config.apiBase}/tabs/${tabId}/chunks${q ? `?${q}` : ""}`);
- if (!res.ok) return { ok: false, chunks: [], total: 0, oldestSeq: null };
- const data = (await res.json()) as {
- chunks?: ChunkRow[];
- total?: number;
- oldestSeq?: number | null;
- };
- const chunks = Array.isArray(data.chunks) ? data.chunks : [];
- return {
- ok: true,
- chunks,
- total: data.total ?? chunks.length,
- oldestSeq: data.oldestSeq ?? minSeqOf(chunks),
- };
- } catch {
- return { ok: false, chunks: [], total: 0, oldestSeq: null };
- }
-}
-
-export interface Tab {
- id: string;
- title: string;
- /**
- * SEALED conversation history as a flat chunk log (real per-tab `seq`).
- * The source of truth for history and the unit of eviction + pagination.
- */
- chunks: ChunkRow[];
- /**
- * Transient render buffer for the CURRENT (unsealed) turn only: the
- * optimistic user message, the in-flight assistant turn (folded from
- * stream deltas), queued/consumed user messages, interrupt splits. Tiny and
- * short-lived — cleared and folded into `chunks` (via refetch) the moment
- * the turn seals. NOT stored history.
- */
- live: ChatMessage[];
- /**
- * Materialized render projection = groupRowsToMessages(chunks) ++ live,
- * recomputed by `updateTab` after any change to `chunks`/`live`. A derived
- * cache for the view layer — NOT the source of truth, never the
- * eviction/pagination unit.
- */
- renderGroups: ChatMessage[];
- /** turn_id of the in-flight turn (stable render keys + reconcile). */
- liveTurnId: string | null;
- agentStatus: "idle" | "running" | "error";
- keyId: string | null;
- modelId: string | null;
- reasoningEffort: ReasoningEffort;
- currentAssistantId: string | null;
- tasks: TaskItem[];
- injectedSkills: string[];
- parentTabId: string | null;
- persistent: boolean;
- agentSlug: string | null;
- agentScope: string | null;
- agentModels: AgentModelEntry[] | null;
- workingDirectory: string | null;
- queuedMessages: QueuedMessage[];
- chunkLimit: number;
- /** Smallest `seq` currently in `chunks` — the backward-pagination cursor. */
- oldestLoadedSeq: number | null;
- /** Total chunk count for this tab on the backend (drives "more to load?"). */
- totalChunks: number;
- /**
- * Unsent chat-input text for THIS tab (in-memory only — never persisted).
- * Saved/restored on tab switch so a draft is never lost or clobbered by
- * switching tabs. Cleared on send.
- */
- draft: string;
- /**
- * Staged image/PDF attachments for THIS tab's unsent draft (in-memory only —
- * never persisted). Each corresponds to an inline `【image:…】`/`【pdf:…】`
- * token in `draft`; removing the token detaches the attachment (reconciled on
- * every keystroke). Ephemeral: sent to the model for one turn, then cleared.
- */
- attachments: StagedAttachment[];
- /**
- * True once the user has manually renamed this tab (double-click rename).
- * Suppresses the first-message auto-title so a chosen name is never
- * clobbered. In-memory only — a renamed tab is no longer "New Tab" on
- * reload, so the auto-title guard already won't fire for it.
- */
- manualTitle: boolean;
- /**
- * Cumulative prompt-cache token telemetry for this tab since the page
- * loaded (in-memory only — resets on reload). Undefined until the first
- * `usage` event arrives. Drives the "Cache Rate" sidebar view.
- */
- cacheStats?: CacheStats;
- /**
- * Compaction UI state. `compactingSource` is set on a TRANSIENT placeholder
- * tab while it hosts the "compacting…" screen, naming the conversation being
- * compacted. `isCompacting` is set on the SOURCE tab while its compaction is
- * in flight (input locked). Both clear when compaction settles.
- */
- compactingSource?: string | null;
- isCompacting?: boolean;
- /** Error message shown on a placeholder tab when compaction fails. */
- compactionError?: string | null;
-}
-
-/**
- * Build a fresh tab store. Exported so tests can construct a real
- * `$state`-backed instance per test — the production singleton is
- * exported below as `tabStore`. The previous test harness duplicated
- * the store logic against POJO arrays, which made the
- * `structuredClone(svelteProxy)` bug undetectable: native `structuredClone`
- * works on plain arrays and throws on Svelte reactive proxies. See the
- * `chat-store.test.ts` rewrite for the proper integration tests that
- * drive the actual reactive code path.
- */
-export function createTabStore() {
- let tabs: Tab[] = $state([]);
- let activeTabId: string | null = $state(null);
- let pendingPermissions: PermissionPrompt[] = $state([]);
- let permissionLog: LogEntry[] = $state([]);
- let configReloaded = $state(false);
- let isConnected = $state(false);
-
- // Track message IDs that were consumed before the POST /chat response arrived.
- // Keyed by queueId — if consumed before we process the response, we skip the queued state.
- const recentlyConsumedIds = new Set<string>();
-
- // Tabs whose UI is currently scrolled up (viewing older history). While a
- // tab is in this set, automatic eviction is suppressed so messages don't
- // vanish out from under the user's viewport. ChatPanel toggles this via
- // `setScrolledUp`. A `force` eviction ignores this set entirely.
- const scrolledUpTabs = new Set<string>();
-
- // tabId → the turn_id whose reconcile was deferred because the user was
- // scrolled up. A Map (not a Set) so the deferred flush knows which turn
- // sealed and can preserve a newer turn that started streaming meanwhile.
- // Flushed when they return to the bottom so we don't yank their viewport.
- const pendingReconcileTabs = new Map<string, string>();
-
- // Clear any stale listeners from HMR reloads, then register
- wsClient.clearCallbacks();
- wsClient.onEvent((event) => {
- handleEvent(event as AgentEvent & { tabId?: string });
- });
-
- // Let the cache-warming store resolve a tab's provider request params
- // (key/model/fallback chain) at fire time, straight from live tab state.
- cacheWarming.setRequestResolver((tabId) => {
- const t = getTabById(tabId);
- if (!t) return null;
- return {
- keyId: t.keyId,
- modelId: t.modelId,
- agentModels: t.agentModels,
- reasoningEffort: t.reasoningEffort,
- };
- });
-
- $effect.root(() => {
- $effect(() => {
- isConnected = wsClient.connectionStatus === "connected";
- });
- });
-
- function getActiveTab(): Tab | undefined {
- return tabs.find((t) => t.id === activeTabId);
- }
-
- function getTabById(id: string): Tab | undefined {
- return tabs.find((t) => t.id === id);
- }
-
- /**
- * Minimum display length of a tab handle (git-style short id). Mirrors
- * `MIN_TAB_PREFIX_LENGTH` in core's `db/tabs.ts` so the handle the user sees
- * is always resolvable by the backend's `resolveTabPrefix`.
- */
- const MIN_HANDLE_LENGTH = 4;
-
- /**
- * Compute the shortest unique prefix (≥ MIN_HANDLE_LENGTH chars) of `tabId`
- * among all currently-open tabs — the displayed "handle" agents use to
- * address each other. Purely DERIVED from the UUIDs already in `tabs`; never
- * stored. Grows by one char only when another open tab shares the prefix, and
- * shrinks back when that sibling closes.
- */
- function shortHandleFor(tabId: string): string {
- const others = tabs.map((t) => t.id).filter((id) => id !== tabId);
- for (let len = MIN_HANDLE_LENGTH; len < tabId.length; len++) {
- const candidate = tabId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return tabId;
- }
-
- async function createNewTab(): Promise<Tab> {
- const id = generateId();
- const title = "New Tab";
-
- // Create on backend
- try {
- await fetch(`${config.apiBase}/tabs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id, title }),
- });
- } catch {
- // Continue even if backend fails — tab works locally
- }
-
- const tab: Tab = {
- id,
- title,
- chunks: [],
- live: [],
- renderGroups: [],
- liveTurnId: null,
- agentStatus: "idle",
- keyId: null,
- modelId: null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: null,
- totalChunks: 0,
- compactingSource: null,
- isCompacting: false,
- compactionError: null,
- };
- tabs = [...tabs, tab];
- activeTabId = id;
- cacheWarming.initTab(id);
-
- // Auto-check default skills then apply default agent (sequential to avoid race)
- void (async () => {
- await autoCheckDefaultSkills();
- await autoSelectDefaultAgent(id);
- })();
-
- return tab;
- }
-
- function switchTab(id: string): void {
- if (tabs.some((t) => t.id === id)) {
- activeTabId = id;
- }
- }
-
- function promoteTab(id: string): void {
- const tab = getTabById(id);
- if (!tab) return;
- updateTab(id, { persistent: true });
- switchTab(id);
- }
-
- async function openAgentTab(agentId: string): Promise<void> {
- const tab = getTabById(agentId);
- if (tab) {
- updateTab(agentId, { persistent: true });
- switchTab(agentId);
- return;
- }
-
- // Tab not found locally — try to fetch from backend
- try {
- const tabRes = await fetch(`${config.apiBase}/tabs/${agentId}`);
- if (!tabRes.ok) return; // 404 or other error — tab doesn't exist
- const tabData = (await tabRes.json()) as {
- id: string;
- title: string;
- keyId?: string | null;
- modelId?: string | null;
- status?: string;
- parentTabId?: string | null;
- };
-
- // Load the tail of the flat chunk log (raw rows — the frontend groups
- // for render and evicts/paginates on the flat list).
- const win = await fetchChunkWindow(agentId, { limit: 100 });
-
- const newTab: Tab = {
- id: agentId,
- title: tabData.title,
- chunks: win.chunks,
- live: [],
- renderGroups: deriveRenderGroups(win.chunks, []),
- liveTurnId: null,
- agentStatus: "idle",
- keyId: tabData.keyId ?? null,
- modelId: tabData.modelId ?? null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: tabData.parentTabId ?? null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- };
- tabs = [...tabs, newTab];
- activeTabId = agentId;
- cacheWarming.initTab(agentId);
- evictChunks(agentId);
- } catch (err) {
- console.error("openAgentTab failed:", err);
- }
- }
-
- async function closeTab(id: string): Promise<void> {
- const tab = getTabById(id);
- if (!tab) return;
-
- cacheWarming.forgetTab(id);
-
- // Archive on backend (also stops any running agent)
- try {
- await fetch(`${config.apiBase}/tabs/${id}`, { method: "DELETE" });
- } catch {
- // Continue with local removal
- }
-
- tabs = tabs.filter((t) => t.id !== id);
-
- // If we closed the active tab, switch to the last remaining or create a new one
- if (activeTabId === id) {
- if (tabs.length > 0) {
- const fallback = tabs[tabs.length - 1];
- if (fallback && !fallback.persistent) {
- updateTab(fallback.id, { persistent: true });
- }
- activeTabId = fallback?.id ?? null;
- } else {
- await createNewTab();
- }
- }
- }
-
- function updateTab(id: string, patch: Partial<Tab>): void {
- tabs = tabs.map((t) => {
- if (t.id !== id) return t;
- const next = { ...t, ...patch };
- // `renderGroups` is a derived cache: recompute it whenever its inputs
- // (`chunks` / `live`) change so the view layer never reads a stale
- // projection. Callers only ever mutate `chunks`/`live`.
- if ("chunks" in patch || "live" in patch) {
- next.renderGroups = deriveRenderGroups(next.chunks, next.live);
- }
- return next;
- });
- }
-
- /**
- * Rename a tab. Records `manualTitle` so the first-message auto-title never
- * clobbers the user's chosen name, and persists the new title to the DB
- * (fire-and-forget — the optimistic local update is the source of truth for
- * the open session).
- */
- function renameTab(id: string, title: string): void {
- const trimmed = title.trim();
- if (!trimmed) return;
- updateTab(id, { title: trimmed, manualTitle: true });
- fetch(`${config.apiBase}/tabs/${id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: trimmed }),
- }).catch(() => {});
- }
-
- /**
- * Reorder the top-row USER tabs to match `orderedUserTabIds`. Subagent tabs
- * (those with a `parentTabId`) keep their relative order untouched — they
- * live in a separate row and aren't draggable. The new left-to-right user
- * order is persisted via `PATCH /tabs/reorder`, which rewrites each open
- * tab's `position` (fire-and-forget, matching the title-persist style).
- */
- function reorderTabs(orderedUserTabIds: string[]): void {
- const byId = new Map(tabs.map((t) => [t.id, t]));
- const ordered = orderedUserTabIds
- .map((id) => byId.get(id))
- .filter((t): t is Tab => t !== undefined && t.parentTabId === null);
- // Bail if the requested order doesn't cover exactly the current user tabs
- // (stale drag against a since-changed tab set) — never drop tabs.
- const currentUserCount = tabs.filter((t) => t.parentTabId === null).length;
- if (ordered.length !== currentUserCount) return;
- const subagentTabs = tabs.filter((t) => t.parentTabId !== null);
- tabs = [...ordered, ...subagentTabs];
- // Persist the full open-tab order (user tabs first, then subagents) so the
- // backend `position` column matches what the user sees on reload.
- const persistOrder = [...ordered, ...subagentTabs].map((t) => t.id);
- fetch(`${config.apiBase}/tabs/reorder`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ids: persistOrder }),
- }).catch(() => {});
- }
-
- /**
- * Persist the unsent chat-input text for a tab (in-memory only). Saved on
- * every keystroke so switching tabs preserves the draft and restoring the
- * target tab shows its own text. No-op if the tab is gone.
- */
- function setDraft(id: string, text: string): void {
- const tab = getTabById(id);
- if (!tab) return;
- // Detach any staged attachment whose inline token is no longer intact in
- // the new draft text (covers atomic-delete, manual mid-token edits, cut,
- // select-all-delete, etc.). The token in the textarea is the ONLY handle
- // on an attachment, so reconciling here keeps the two in lockstep.
- const intact = intactTokenIds(text);
- const keep = tab.attachments.filter((a) => intact.has(a.id));
- if (keep.length !== tab.attachments.length) {
- updateTab(id, { draft: text, attachments: keep });
- } else {
- updateTab(id, { draft: text });
- }
- }
-
- /**
- * Stage a pasted attachment on a tab. The caller is responsible for also
- * inserting the matching `【image:…】`/`【pdf:…】` token into the draft (the
- * token is what keeps the attachment alive through reconciliation). No-op if
- * the tab is gone.
- */
- function addAttachment(id: string, attachment: StagedAttachment): void {
- const tab = getTabById(id);
- if (!tab) return;
- updateTab(id, { attachments: [...tab.attachments, attachment] });
- }
-
- /**
- * Record whether a tab's chat view is scrolled up (viewing older history).
- * Used to suppress automatic eviction while the user is reading old
- * messages — we don't want to delete what they're currently looking at.
- */
- function setScrolledUp(tabId: string, scrolledUp: boolean): void {
- if (scrolledUp) {
- scrolledUpTabs.add(tabId);
- } else {
- scrolledUpTabs.delete(tabId);
- // Returned to the bottom — run any reconcile we deferred while reading.
- const deferredTurnId = pendingReconcileTabs.get(tabId);
- if (deferredTurnId !== undefined) {
- pendingReconcileTabs.delete(tabId);
- reconcileSealedTurn(tabId, deferredTurnId);
- }
- }
- }
-
- /**
- * Drop up to `n` of the oldest chunks from the live tail (front-to-back
- * across its messages), never removing the chunk currently being streamed
- * (the last chunk of the in-flight assistant message). Emptied messages are
- * dropped. Used only when a single in-flight turn alone exceeds the budget.
- */
- function trimLiveChunks(
- live: ChatMessage[],
- n: number,
- streamingId: string | null,
- ): ChatMessage[] {
- let remaining = n;
- const out = live.map((m) => ({ ...m, chunks: [...m.chunks] }));
- for (const m of out) {
- if (remaining <= 0) break;
- const isStreamingMsg = m.id === streamingId || m.isStreaming === true;
- while (m.chunks.length > 0 && remaining > 0) {
- // Keep the last (open) chunk of the actively streaming message.
- if (isStreamingMsg && m.chunks.length === 1) break;
- m.chunks.shift();
- remaining--;
- }
- }
- return out.filter((m) => m.chunks.length > 0);
- }
-
- /**
- * Bound a tab's in-memory footprint to `chunkLimit` by rolling eviction of
- * the OLDEST chunks. Sealed history (`tab.chunks`) is trimmed from the front
- * first; if a single in-flight turn alone still exceeds the budget, the
- * oldest chunks of the live tail are trimmed too (never the chunk currently
- * being streamed). Evicted sealed chunks are re-fetched on scroll-up via
- * `loadOlderChunks`; live chunks that haven't sealed yet are recovered by
- * the turn-completion reconcile once their write lands. Suppressed while
- * scrolled up unless `force` is set.
- */
- function evictChunks(tabId: string, force = false): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- if (!force && scrolledUpTabs.has(tabId)) return;
-
- const limit = appSettings.chunkLimit;
- if (!Number.isFinite(limit) || limit <= 0) return;
-
- let sealed = tab.chunks;
- let live = tab.live;
- let total = sealed.length + countLiveChunks(live);
- if (total <= limit) return;
-
- // 1. Drop oldest sealed chunk rows from the front.
- if (sealed.length > 0) {
- let dropTo = 0;
- while (total > limit && dropTo < sealed.length) {
- dropTo++;
- total--;
- }
- if (dropTo > 0) sealed = sealed.slice(dropTo);
- }
-
- // 2. Still over budget → one live turn exceeds the limit on its own.
- if (total > limit && live.length > 0) {
- live = trimLiveChunks(live, total - limit, tab.currentAssistantId);
- }
-
- updateTab(tabId, {
- chunks: sealed,
- live,
- oldestLoadedSeq: minSeqOf(sealed) ?? tab.oldestLoadedSeq,
- });
- }
-
- /**
- * Fetch and prepend the next older page of CHUNKS (raw rows). Called when
- * the user scrolls toward the top. Pages backward by the oldest loaded
- * `seq` (`?before=`), dedupes by `seq`, and keeps the window seq-sorted —
- * so a turn split across the window boundary regroups into one bubble with
- * no special-casing. Does NOT evict (the user is reading history).
- */
- async function loadOlderChunks(tabId: string): Promise<void> {
- const tab = getTabById(tabId);
- if (!tab) return;
- const before = tab.oldestLoadedSeq;
- const win = await fetchChunkWindow(tabId, {
- limit: 50,
- ...(before !== null ? { before } : {}),
- });
- const current = getTabById(tabId);
- if (!current) return;
- if (win.chunks.length === 0) {
- // Nothing older; refresh the total if the backend reported a real one.
- if (win.total > 0) updateTab(tabId, { totalChunks: win.total });
- return;
- }
- const merged = mergeChunksBySeq(current.chunks, win.chunks);
- updateTab(tabId, {
- chunks: merged,
- oldestLoadedSeq: minSeqOf(merged),
- totalChunks: win.total,
- });
- }
-
- function ensureAssistantMessage(tabId: string): ChatMessage | null {
- const tab = getTabById(tabId);
- if (!tab) return null;
-
- if (tab.currentAssistantId) {
- const existing = tab.live.find((m) => m.id === tab.currentAssistantId);
- if (existing) return existing;
- }
-
- const id = generateId();
- const newMsg: ChatMessage = {
- id,
- role: "assistant",
- chunks: [],
- isStreaming: true,
- ...(tab.liveTurnId !== null ? { turnId: tab.liveTurnId } : {}),
- };
- updateTab(tabId, {
- currentAssistantId: id,
- live: [...tab.live, newMsg],
- });
- evictChunks(tabId);
- return newMsg;
- }
-
- /**
- * Update the live tail (the current unsealed turn). All streaming handlers
- * operate here; sealed history (`tab.chunks`) is never touched by streaming.
- */
- function updateLive(tabId: string, updater: (live: ChatMessage[]) => ChatMessage[]): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- updateTab(tabId, { live: updater(tab.live) });
- }
-
- /**
- * Apply a content-producing event to the in-flight assistant message via the
- * shared core helper.
- *
- * Reactivity contract: `appendEventToChunks` mutates the chunks array in
- * place, but Svelte 5 `$state` only triggers updates when we reassign at the
- * `tabs` array level. We snapshot the message's chunks via
- * `$state.snapshot` (Svelte's own safe clone — strips reactive proxies and
- * falls back gracefully where native `structuredClone` would throw
- * `DataCloneError` on a `$state` proxy), mutate the snapshot, then write
- * it back through `updateLive`. The previous use of `structuredClone`
- * here threw silently and was swallowed by the WS try/catch — left chunks
- * empty for every streaming turn.
- */
- function applyChunkEvent(tabId: string, event: AgentEvent): void {
- ensureAssistantMessage(tabId);
- const tab = getTabById(tabId);
- if (!tab) return;
- const currentId = tab.currentAssistantId;
- if (!currentId) return;
- updateLive(tabId, (msgs) =>
- msgs.map((m) => {
- if (m.id !== currentId) return m;
- const cloned = $state.snapshot(m.chunks) as Chunk[];
- // The frontend's local AgentEvent is structurally compatible with
- // core's for every variant the helper cares about; the variants
- // where shapes differ (tab-created, done, status, message-*) are
- // all in the helper's no-op branch.
- appendEventToChunks(cloned, event as unknown as Parameters<typeof appendEventToChunks>[1]);
- return { ...m, chunks: cloned, isStreaming: true };
- }),
- );
- // A chunk may have just completed — keep the in-memory footprint bounded.
- evictChunks(tabId);
- }
-
- /**
- * Route a system event when there's no in-flight assistant turn. Wraps
- * `applySystemEvent` from core, which either appends a `system` chunk to
- * the most recent `role: "system"` message or creates a new one.
- */
- function routeSystemEvent(tabId: string, sysEvent: SystemEventLike): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- // Operate on the live tail (applySystemEvent appends a system chunk to
- // the trailing system message or creates one). Build a shallow-cloned
- // IdentifiedMessage[] view via `$state.snapshot` (safe against Svelte 5
- // reactive proxies; native `structuredClone` would throw), run the
- // helper, then write it back. The backend persists this system row too,
- // so it reconciles into `chunks` on the next turn/load.
- const view: IdentifiedMessage[] = tab.live.map((m) => ({
- id: m.id,
- role: m.role,
- chunks: $state.snapshot(m.chunks) as Chunk[],
- }));
- applySystemEvent(view, sysEvent, generateId);
-
- // Reconcile: rebuild the live array from the view, preserving existing
- // message metadata (isStreaming, debugInfo) where IDs match.
- const byId = new Map(tab.live.map((m) => [m.id, m]));
- const rebuilt: ChatMessage[] = view.map((v) => {
- const existing = byId.get(v.id);
- if (existing) {
- return { ...existing, role: v.role, chunks: v.chunks as Chunk[] };
- }
- return {
- id: v.id,
- role: v.role,
- chunks: v.chunks as Chunk[],
- isStreaming: false,
- };
- });
- updateTab(tabId, { live: rebuilt });
- }
-
- /**
- * Reload a tab's chunk window from the API and fold the sealed turn out of
- * the live tail. The persisted chunk log is the source of truth. Two modes:
- * - turn-completion reconcile (`preserveActiveTurn=true`, `sealedTurnId`
- * set): the just-sealed turn's rows now carry real seqs. Drop that turn
- * from `live`, but PRESERVE (a) a newer turn that began streaming while a
- * reconcile was deferred — the queued-message race — and (b) optimistic
- * user messages not yet bound to a turn, so neither is wiped.
- * - WS-reconnect desync (`preserveActiveTurn=false`): the backend has moved
- * on and is idle, so trust the DB fully and clear the live tail.
- * A failed fetch is a no-op (never wipes a populated tab).
- */
- async function reloadChunksFromApi(
- tabId: string,
- preserveActiveTurn = false,
- sealedTurnId?: string,
- ): Promise<void> {
- const win = await fetchChunkWindow(tabId, { limit: 100 });
- if (!win.ok) return;
- const current = getTabById(tabId);
- if (!current) return;
- // A turn that started streaming AFTER the one being reconciled must not be
- // wiped — only the sealed turn folds into `chunks`.
- const preserveTurnId =
- preserveActiveTurn && current.liveTurnId !== null && current.liveTurnId !== sealedTurnId
- ? current.liveTurnId
- : null;
- const keptLive = preserveActiveTurn
- ? current.live.filter(
- (m) =>
- (preserveTurnId !== null && m.turnId === preserveTurnId) ||
- // Optimistic / queued user messages not yet bound to a turn.
- (m.turnId === undefined && m.role === "user"),
- )
- : [];
- const stillActive = preserveTurnId !== null;
- updateTab(tabId, {
- chunks: win.chunks,
- live: keptLive,
- liveTurnId: stillActive ? current.liveTurnId : null,
- currentAssistantId: stillActive ? current.currentAssistantId : null,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- });
- evictChunks(tabId);
- }
-
- /**
- * Turn-completion reconcile. On `turn-sealed`, fold the just-finished turn
- * (`sealedTurnId`) into the sealed log by reloading the chunk window (real
- * seqs) and dropping that turn from the live tail — while preserving any
- * newer in-flight turn and not-yet-sealed optimistic user messages. Deferred
- * while the user is scrolled up so the viewport isn't disturbed; re-attempted
- * (with the same `sealedTurnId`) when they return to the bottom.
- */
- function reconcileSealedTurn(tabId: string, sealedTurnId: string): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- if (tab.live.length === 0 && tab.liveTurnId === null) return;
- if (scrolledUpTabs.has(tabId)) {
- pendingReconcileTabs.set(tabId, sealedTurnId);
- return;
- }
- pendingReconcileTabs.delete(tabId);
- void reloadChunksFromApi(tabId, true, sealedTurnId);
- }
-
- /**
- * Hydrate the tab store from the backend on app mount. Restores the
- * full list of open tabs (every row with `is_open = 1` in the DB),
- * loads each tab's persisted message history, and seeds the in-flight
- * assistant message for any tab the backend is currently streaming.
- *
- * Wire calls:
- * - GET /tabs → list of open tabs in `position` order
- * - GET /tabs/:id/messages → persisted ChatMessage[] for each
- * - GET /status → in-flight TabStatusSnapshot map
- *
- * Failure modes (all log + continue with whatever was successfully
- * hydrated; callers fall back to creating a fresh tab if the final
- * `tabs` array is empty):
- * - /tabs request fails → no tabs restored
- * - /tabs/:id/messages fails → that tab restored with empty messages
- * - /status fails → tabs restored, in-flight streaming will be
- * lost (will surface as a static "running" status until the next
- * event arrives); harmless because the WS will broadcast `statuses`
- * on reconnect anyway.
- *
- * Returns the number of tabs hydrated (0 on total failure, ≥1 on
- * partial or full success). Caller uses this to decide whether to
- * create a fresh tab.
- *
- * Idempotency: if `tabs.length > 0` when called, returns 0 without
- * touching state — the caller already has tabs from elsewhere (e.g.
- * a hot-reload that preserved Svelte state).
- */
- async function hydrateFromBackend(): Promise<number> {
- if (tabs.length > 0) return 0;
-
- // 1. Fetch the list of open tabs from the DB.
- let tabRows: Array<{
- id: string;
- title: string;
- keyId?: string | null;
- modelId?: string | null;
- parentTabId?: string | null;
- // Backend usage aggregate (GET /tabs). Structurally identical to
- // CacheStats, so it seeds `cacheStats` directly on reload. This is the
- // initial seed (hydrate runs only when tabs.length === 0, i.e. a true
- // reload); thereafter `turn-sealed` REPLACES cacheStats with the same
- // aggregate each turn, keeping the live accumulator reconciled to the DB
- // truth. Neither path ADDS to live events, so there is no double-count.
- usageStats?: CacheStats | null;
- }> = [];
- try {
- const res = await fetch(`${config.apiBase}/tabs`);
- if (!res.ok) return 0;
- const data = (await res.json()) as { tabs?: typeof tabRows };
- tabRows = Array.isArray(data.tabs) ? data.tabs : [];
- } catch {
- return 0;
- }
-
- if (tabRows.length === 0) return 0;
-
- // 2. Fetch the in-flight snapshot. Failure is non-fatal.
- let statusMap: Record<string, TabStatusSnapshot> = {};
- try {
- const res = await fetch(`${config.apiBase}/status`);
- if (res.ok) {
- const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> };
- if (data.statuses && typeof data.statuses === "object") {
- statusMap = data.statuses;
- }
- }
- } catch {
- // Non-fatal: tabs still restore with idle status.
- }
-
- // 3. For each tab, fetch its chunk window (raw rows) in parallel.
- type Win = { ok: boolean; chunks: ChunkRow[]; total: number; oldestSeq: number | null };
- const winByTab = new Map<string, Win>();
- for (const { id, win } of await Promise.all(
- tabRows.map(async (row) => ({
- id: row.id,
- win: await fetchChunkWindow(row.id, { limit: 100 }),
- })),
- )) {
- winByTab.set(id, win);
- }
-
- // 4. Build the Tab objects, seeding the in-flight live turn for running
- // tabs from the status snapshot (the unsealed turn isn't in the DB
- // yet; it reconciles into `chunks` when `turn-sealed` arrives).
- const restored: Tab[] = tabRows.map((row) => {
- const snap = statusMap[row.id];
- const win: Win = winByTab.get(row.id) ?? { ok: true, chunks: [], total: 0, oldestSeq: null };
- const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle";
-
- let currentAssistantId: string | null = null;
- let liveTurnId: string | null = null;
- let live: ChatMessage[] = [];
-
- if (agentStatus === "running" && snap?.currentAssistantId) {
- currentAssistantId = snap.currentAssistantId;
- liveTurnId = snap.currentTurnId ?? null;
- live = [
- {
- id: snap.currentAssistantId,
- role: "assistant",
- chunks: snap.currentChunks ? [...snap.currentChunks] : [],
- isStreaming: true,
- ...(liveTurnId !== null ? { turnId: liveTurnId } : {}),
- },
- ];
- }
-
- return {
- id: row.id,
- title: row.title,
- chunks: win.chunks,
- live,
- renderGroups: deriveRenderGroups(win.chunks, live),
- liveTurnId,
- agentStatus,
- keyId: row.keyId ?? null,
- modelId: row.modelId ?? null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId,
- // Rehydrate the todo list from the backend snapshot so a reload
- // doesn't blank the Tasks panel mid-task.
- tasks: snap?.tasks ?? [],
- injectedSkills: [],
- parentTabId: row.parentTabId ?? null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- cacheStats: row.usageStats ?? undefined,
- };
- });
-
- tabs = restored;
- // Trim each restored tab down to the chunk limit (user starts at bottom).
- for (const t of restored) {
- evictChunks(t.id);
- // Seed warming from persisted per-tab preference. Arms the 4-minute
- // countdown for idle+enabled tabs; running tabs stay paused until
- // their next `status`/`statuses` reconcile flips them idle.
- cacheWarming.initTab(t.id);
- if (t.agentStatus === "running") cacheWarming.onTurnActive(t.id);
- }
- // Activate the first restored tab (the list is already ordered by
- // `position` from the backend).
- activeTabId = restored[0]?.id ?? null;
- return restored.length;
- }
-
- /**
- * Start a conversation compaction (UI-driven). Creates a TRANSIENT
- * placeholder tab that shows the "compacting…" screen, switches to it, and
- * kicks off the backend compaction of `sourceTabId`. Outcome arrives via the
- * `compaction-*` WS events (see handleEvent). Closing the placeholder tab
- * before completion cancels it (DELETE aborts the in-flight summary).
- */
- async function startCompaction(sourceTabId: string): Promise<void> {
- const source = getTabById(sourceTabId);
- if (!source) return;
- if (source.isCompacting) return;
-
- const tempId = generateId();
- // Create the placeholder tab on the backend (so DELETE-on-close can
- // abort the run) and locally (so we can switch to it and show the UI).
- try {
- await fetch(`${config.apiBase}/tabs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: tempId, title: "Compacting…" }),
- });
- } catch {
- // Continue — the run is driven server-side via the compact endpoint.
- }
-
- const placeholder: Tab = {
- id: tempId,
- title: "Compacting…",
- chunks: [],
- live: [],
- renderGroups: [],
- liveTurnId: null,
- agentStatus: "idle",
- keyId: null,
- modelId: null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- manualTitle: true,
- oldestLoadedSeq: null,
- totalChunks: 0,
- attachments: [],
- compactingSource: sourceTabId,
- isCompacting: false,
- compactionError: null,
- };
- tabs = [...tabs, placeholder];
- activeTabId = tempId;
- updateTab(sourceTabId, { isCompacting: true });
-
- try {
- const res = await fetch(`${config.apiBase}/tabs/${tempId}/compact`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ sourceTabId }),
- });
- if (!res.ok) {
- const msg = `Compaction request failed (HTTP ${res.status}).`;
- updateTab(sourceTabId, { isCompacting: false });
- if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
- }
- } catch {
- const msg = "Could not reach the server to start compaction.";
- updateTab(sourceTabId, { isCompacting: false });
- if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
- }
- }
-
- /**
- * Finish a completed compaction: the canonical conversation now lives on
- * `sourceTabId` (re-seeded with summary + preserved tail), the full prior
- * history was relocated to `backupTabId`. Reload the source tab's chunks,
- * insert the backup tab into the sidebar, switch focus back to the source,
- * and discard the transient placeholder.
- */
- async function finishCompaction(ev: {
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }): Promise<void> {
- // Reload the re-seeded source conversation from the backend.
- updateTab(ev.sourceTabId, { isCompacting: false });
- await reloadChunksFromApi(ev.sourceTabId);
-
- // Insert the backup tab (full pre-compaction history) if not present.
- if (!getTabById(ev.backupTabId)) {
- const win = await fetchChunkWindow(ev.backupTabId, { limit: 100 });
- const src = getTabById(ev.sourceTabId);
- const backup: Tab = {
- id: ev.backupTabId,
- title: ev.backupTitle,
- chunks: win.chunks,
- live: [],
- renderGroups: deriveRenderGroups(win.chunks, []),
- liveTurnId: null,
- agentStatus: "idle",
- keyId: src?.keyId ?? null,
- modelId: src?.modelId ?? null,
- reasoningEffort: src?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: null,
- persistent: true,
- agentSlug: src?.agentSlug ?? null,
- agentScope: src?.agentScope ?? null,
- agentModels: src?.agentModels ?? null,
- workingDirectory: src?.workingDirectory ?? null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- manualTitle: true,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- attachments: [],
- compactingSource: null,
- isCompacting: false,
- compactionError: null,
- };
- tabs = [...tabs, backup];
- evictChunks(ev.backupTabId);
- }
-
- // Switch focus back to the (compacted) source tab and drop the
- // placeholder. If the placeholder was the active tab, focus moves to
- // the source conversation.
- if (activeTabId === ev.tempTabId) activeTabId = ev.sourceTabId;
- tabs = tabs.filter((t) => t.id !== ev.tempTabId);
- }
-
- function handleEvent(event: AgentEvent & { tabId?: string }): void {
- const tabId = event.tabId;
-
- switch (event.type) {
- case "status": {
- if (!tabId) break;
- updateTab(tabId, { agentStatus: event.status });
- // Cache warming never fires mid-turn: pause it while running, and
- // re-arm the 4-minute countdown once the turn ends (idle/error).
- if (event.status === "running") {
- cacheWarming.onTurnActive(tabId);
- }
- if (event.status === "idle" || event.status === "error") {
- // Stop the streaming cursor immediately; the fold of the live
- // tail into the sealed chunk log happens on `turn-sealed`
- // (after the DB write lands — status fires before it).
- updateLive(tabId, (msgs) =>
- msgs.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
- );
- updateTab(tabId, { currentAssistantId: null });
- const tab = getTabById(tabId);
- if (tab && !tab.persistent && tabId !== activeTabId) {
- cacheWarming.removeTab(tabId);
- tabs = tabs.filter((t) => t.id !== tabId);
- } else {
- cacheWarming.onTurnEnded(tabId);
- }
- }
- break;
- }
- case "turn-start": {
- if (!tabId) break;
- const tsTab = getTabById(tabId);
- // Tag the in-flight turn. Also backfill the turn_id onto THIS
- // turn's initiating optimistic user message — it was created on
- // send before the turn_id was known — so it key-matches the sealed
- // user row after reconcile (flicker-free; no remount).
- //
- // A turn-start corresponds to exactly one persisted user row
- // (processMessage → explodeUserText), and a queued message never
- // gets its own turn-start (it is drained into a running turn via
- // message-consumed). So the initiator is the single most-recent
- // NON-queued untagged user row. We must NOT tag pending `queued-`
- // rows: they belong to future turns, and tagging them here would
- // wipe them from the UI when THIS turn seals (reconcile drops live
- // rows bound to the sealed turn).
- const taggedLive = tsTab
- ? (() => {
- const live = [...tsTab.live];
- for (let i = live.length - 1; i >= 0; i--) {
- const m = live[i];
- // Stop at the first non-user row (assistant/system
- // boundary): earlier user rows belong to prior turns.
- if (!m || m.role !== "user") break;
- // Skip past pending queued messages (future turns).
- if (m.id.startsWith("queued-")) continue;
- // Most-recent non-queued user row = this turn's
- // initiator. Tag it once (if untagged), then stop.
- if (m.turnId === undefined) {
- live[i] = { ...m, turnId: event.turnId };
- }
- break;
- }
- return live;
- })()
- : undefined;
- updateTab(tabId, {
- liveTurnId: event.turnId,
- ...(taggedLive ? { live: taggedLive } : {}),
- });
- break;
- }
- case "turn-sealed": {
- if (!tabId) break;
- // The turn's rows are now durable — fold THIS turn out of the live
- // tail into the sealed chunk log (refetch real seqs), preserving any
- // newer in-flight turn. Deferred while scrolled up.
- reconcileSealedTurn(tabId, event.turnId);
- // Reconcile cacheStats to the DB source-of-truth carried on the event.
- // REPLACE (not add): the aggregate already includes every persisted
- // usage row for this tab, so this both lands the just-sealed turn's
- // usage AND self-heals any live overshoot (e.g. a rate-limited
- // fallback attempt streamed usage live but was discarded server-side).
- // `usageStats === undefined` (older backend) leaves cacheStats as-is.
- if (event.usageStats !== undefined) {
- updateTab(tabId, { cacheStats: event.usageStats ?? undefined });
- }
- break;
- }
- case "statuses": {
- // WS (re)connect snapshot. The shape was widened to
- // TabStatusSnapshot (status + optional currentChunks +
- // optional currentAssistantId) so the frontend can seed
- // in-flight assistant messages on browser reopen.
- const backend = event.statuses;
- for (const t of tabs) {
- const snap = backend[t.id];
- const backendStatus = snap?.status ?? "idle";
-
- // Desync case: frontend thought it was streaming, backend
- // has already moved on. The turn is persisted now — reload
- // the chunk window so the final answer shows up.
- if (t.agentStatus === "running" && backendStatus !== "running") {
- void reloadChunksFromApi(t.id);
- }
-
- // Status alignment.
- if (t.agentStatus !== backendStatus) {
- updateTab(t.id, { agentStatus: backendStatus });
- }
-
- // Sync cache warming to the reconciled status: pause it while a
- // tab is (still) running, otherwise (re-)arm the idle countdown.
- if (backendStatus === "running") {
- cacheWarming.onTurnActive(t.id);
- } else {
- cacheWarming.onTurnEnded(t.id);
- }
-
- // Rehydrate the todo list from the snapshot (backend truth)
- // so a reconnect/reload doesn't blank the Tasks panel.
- updateTab(t.id, { tasks: snap?.tasks ?? [] });
-
- if (backendStatus === "running") {
- // Seed the in-flight assistant message from the snapshot.
- // This handles the "browser just reopened mid-stream"
- // path: the DB only has chunks up to the last
- // flushAssistant call, but the snapshot has the live
- // in-memory currentChunks.
- if (snap?.currentAssistantId) {
- const targetId = snap.currentAssistantId;
- updateTab(t.id, {
- currentAssistantId: targetId,
- ...(snap.currentTurnId ? { liveTurnId: snap.currentTurnId } : {}),
- });
- updateLive(t.id, (msgs) => {
- const idx = msgs.findIndex((m) => m.id === targetId);
- if (idx >= 0) {
- return msgs.map((m, i) =>
- i === idx
- ? {
- ...m,
- chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks,
- isStreaming: true,
- }
- : m,
- );
- }
- return [
- ...msgs,
- {
- id: targetId,
- role: "assistant",
- chunks: snap.currentChunks ? [...snap.currentChunks] : [],
- isStreaming: true,
- ...(snap.currentTurnId ? { turnId: snap.currentTurnId } : {}),
- },
- ];
- });
- }
- } else if (t.currentAssistantId) {
- // Not running: clear streaming flags.
- updateLive(t.id, (msgs) =>
- msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)),
- );
- updateTab(t.id, { currentAssistantId: null });
- }
- }
- break;
- }
- case "reasoning-delta":
- case "reasoning-end":
- case "text-delta":
- case "tool-call":
- case "tool-result":
- case "shell-output": {
- if (!tabId) break;
- applyChunkEvent(tabId, event);
- break;
- }
- case "usage": {
- if (!tabId) break;
- const tab = getTabById(tabId);
- if (!tab) break;
- const u = event.usage;
- const prev = tab.cacheStats;
- updateTab(tabId, {
- cacheStats: {
- inputTokens: (prev?.inputTokens ?? 0) + u.inputTokens,
- outputTokens: (prev?.outputTokens ?? 0) + u.outputTokens,
- cacheReadTokens: (prev?.cacheReadTokens ?? 0) + u.cacheReadTokens,
- cacheWriteTokens: (prev?.cacheWriteTokens ?? 0) + u.cacheWriteTokens,
- requests: (prev?.requests ?? 0) + 1,
- last: {
- inputTokens: u.inputTokens,
- outputTokens: u.outputTokens,
- cacheReadTokens: u.cacheReadTokens,
- cacheWriteTokens: u.cacheWriteTokens,
- },
- },
- });
- break;
- }
- case "done": {
- if (!tabId) break;
- const tab5 = getTabById(tabId);
- if (!tab5) break;
- updateLive(tabId, (msgs) =>
- msgs.map((m) => (m.id === tab5.currentAssistantId ? { ...m, isStreaming: false } : m)),
- );
- updateTab(tabId, { currentAssistantId: null });
- break;
- }
- case "error": {
- if (!tabId) break;
- const errTab = getTabById(tabId);
- if (!errTab) break;
- if (errTab.currentAssistantId) {
- // In-flight turn: append the error as a chunk on the
- // assistant message via the shared helper. Mark debug info
- // on the message for parity with the previous behavior.
- applyChunkEvent(tabId, event);
- updateLive(tabId, (msgs) =>
- msgs.map((m) =>
- m.id === errTab.currentAssistantId
- ? {
- ...m,
- isStreaming: false,
- debugInfo: makeDebugInfo({ error: event.error }),
- }
- : m,
- ),
- );
- } else {
- // No turn in flight: open a new assistant message holding
- // only the error chunk. We do this by ensuring an assistant
- // message then funneling through applyChunkEvent, which
- // guarantees the chunk shape matches the helper's output.
- ensureAssistantMessage(tabId);
- applyChunkEvent(tabId, event);
- const afterTab = getTabById(tabId);
- if (afterTab?.currentAssistantId) {
- const newId = afterTab.currentAssistantId;
- updateLive(tabId, (msgs) =>
- msgs.map((m) =>
- m.id === newId
- ? {
- ...m,
- isStreaming: false,
- debugInfo: makeDebugInfo({ error: event.error }),
- }
- : m,
- ),
- );
- }
- }
- updateTab(tabId, { currentAssistantId: null, agentStatus: "error" });
- break;
- }
- case "notice": {
- if (!tabId) break;
- const noticeTab = getTabById(tabId);
- if (!noticeTab) break;
- if (noticeTab.currentAssistantId) {
- applyChunkEvent(tabId, event);
- } else {
- routeSystemEvent(tabId, { kind: "notice", text: event.message });
- }
- break;
- }
- case "model-changed": {
- if (!tabId) break;
- const mcTab2 = getTabById(tabId);
- if (!mcTab2) break;
- // Always update the tab's active key/model. Additionally emit
- // a `system` chunk to record the switch at its temporal
- // position (in the assistant turn if one is in flight; else
- // in a standalone system message).
- updateTab(tabId, { keyId: event.keyId, modelId: event.modelId });
- if (mcTab2.currentAssistantId) {
- applyChunkEvent(tabId, event);
- } else {
- routeSystemEvent(tabId, {
- kind: "model-changed",
- text: `Switched to ${event.modelId} (${event.keyId})`,
- });
- }
- break;
- }
- case "permission-prompt": {
- pendingPermissions = event.pending;
- break;
- }
- case "task-list-update": {
- if (tabId) {
- updateTab(tabId, { tasks: event.tasks });
- }
- break;
- }
- case "config-reload": {
- configReloaded = true;
- setTimeout(() => {
- configReloaded = false;
- }, 2500);
- // If a tab + turn is in flight, also record the reload as a
- // system chunk for honest history. If no turn is in flight we
- // could route to a system message, but config-reload is a
- // global signal not scoped to any tab — only the active tab,
- // if any, gets the chunk.
- if (tabId) {
- const crTab = getTabById(tabId);
- if (crTab?.currentAssistantId) {
- applyChunkEvent(tabId, event);
- }
- }
- break;
- }
- case "tab-created": {
- const newTabEvent = event as AgentEvent & {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory: string | null;
- agentModels?: AgentModelEntry[] | null;
- };
- // Only add if we don't already have this tab
- if (!getTabById(newTabEvent.id)) {
- const tab: Tab = {
- id: newTabEvent.id,
- title: newTabEvent.title,
- chunks: [],
- live: [],
- renderGroups: [],
- liveTurnId: null,
- agentStatus: "running",
- keyId: newTabEvent.keyId ?? null,
- modelId: newTabEvent.modelId ?? null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: newTabEvent.parentTabId ?? null,
- persistent: newTabEvent.parentTabId == null,
- agentSlug: newTabEvent.agentSlug ?? null,
- agentScope: null,
- agentModels: newTabEvent.agentModels ?? null,
- workingDirectory: newTabEvent.workingDirectory ?? null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: null,
- totalChunks: 0,
- };
- tabs = [...tabs, tab];
- }
- break;
- }
- case "message-queued": {
- if (!tabId) break;
- const mqEvent = event as AgentEvent & { tabId: string; messageId: string; message: string };
- const mqTab = getTabById(tabId);
- if (!mqTab) break;
- // Only add to queuedMessages if not already tracked (might have been added
- // optimistically by sendMessage using the same queueId)
- const alreadyQueued = mqTab.queuedMessages.some((m) => m.id === mqEvent.messageId);
- if (!alreadyQueued) {
- // Message came from another client/session — add it fresh
- const qm: QueuedMessage = {
- id: mqEvent.messageId,
- message: mqEvent.message,
- timestamp: Date.now(),
- };
- updateTab(tabId, { queuedMessages: [...mqTab.queuedMessages, qm] });
- // Also add as a user message in the live tail if not present.
- const tabAfterQm = getTabById(tabId);
- const existingMsg = tabAfterQm?.live.find(
- (m) => m.id === `queued-${mqEvent.messageId}` || m.id === mqEvent.messageId,
- );
- if (!existingMsg) {
- const userMsg: ChatMessage = {
- id: `queued-${mqEvent.messageId}`,
- role: "user",
- chunks: [{ type: "text", text: mqEvent.message }],
- };
- updateTab(tabId, { live: [...(tabAfterQm?.live ?? []), userMsg] });
- }
- }
- // If alreadyQueued, the optimistic update already put everything in place with the
- // correct `queued-${messageId}` id — nothing more to do.
- break;
- }
- case "message-consumed": {
- if (!tabId) break;
- const mcEvent = event as AgentEvent & {
- tabId: string;
- messageIds: string[];
- reason?: "interrupt" | "continuation";
- };
- const mcTab = getTabById(tabId);
- if (!mcTab) break;
- // Track recently consumed IDs so sendMessage can detect early consumption
- for (const id of mcEvent.messageIds) {
- recentlyConsumedIds.add(id);
- setTimeout(() => recentlyConsumedIds.delete(id), 10000);
- }
- updateTab(tabId, {
- queuedMessages: mcTab.queuedMessages.filter((m) => !mcEvent.messageIds.includes(m.id)),
- });
-
- // "continuation" — these queued messages are draining BETWEEN turns
- // to START a fresh turn (the "queue consumed after turn ends" path),
- // not folding into a running turn's tool result. The backend joins
- // them into ONE initiating user row, so we collapse the matching
- // optimistic `queued-` bubbles into a single UNTAGGED user row. It
- // stays untagged on purpose: the imminent `turn-start` tags it as
- // this new turn's initiator (exactly like a normal send), and
- // reconcile then folds it into the sealed turn. Leaving N separate
- // untagged rows would strand all but the most-recent one (turn-start
- // only tags one), so collapsing is required.
- if (mcEvent.reason === "continuation") {
- updateLive(tabId, (msgs) => {
- const consumedTexts: string[] = [];
- const rest: ChatMessage[] = [];
- let firstConsumedIdx = -1;
- for (const m of msgs) {
- if (m.role === "user" && m.id.startsWith("queued-")) {
- const queuedId = m.id.slice(7);
- if (mcEvent.messageIds.includes(queuedId)) {
- if (firstConsumedIdx === -1) firstConsumedIdx = rest.length;
- const textChunk = m.chunks.find((c) => c.type === "text");
- consumedTexts.push(textChunk && textChunk.type === "text" ? textChunk.text : "");
- continue;
- }
- }
- rest.push(m);
- }
- if (consumedTexts.length === 0) return msgs;
- const initiator: ChatMessage = {
- id: generateId(),
- role: "user",
- chunks: [{ type: "text", text: consumedTexts.join("\n---\n") }],
- };
- rest.splice(firstConsumedIdx === -1 ? rest.length : firstConsumedIdx, 0, initiator);
- return rest;
- });
- break;
- }
-
- // Split the current assistant message: finalize it, then insert
- // the consumed user messages after it. Subsequent streaming events
- // will create a NEW assistant message block below.
- const currentAssistantId = mcTab.currentAssistantId;
- updateLive(tabId, (msgs) => {
- // Extract consumed messages
- const consumed: ChatMessage[] = [];
- const rest: ChatMessage[] = [];
- for (const m of msgs) {
- if (m.id.startsWith("queued-")) {
- const queuedId = m.id.slice(7);
- if (mcEvent.messageIds.includes(queuedId)) {
- // Bind the consumed message to the in-flight turn that is
- // consuming it. Stripping the `queued-` prefix alone leaves
- // it an UNTAGGED user row, which reconcileSealedTurn KEEPS —
- // so the interrupt bubble would linger in the live tail
- // forever AND duplicate the `[USER INTERRUPT]` text the
- // backend folds into the sealed tool-result chunk. Tagging
- // it lets reconcile drop it on seal, collapsing to the
- // persisted shape. (liveTurnId is set for the duration of a
- // running turn, which is the only time a consume happens.)
- consumed.push({
- ...m,
- id: queuedId,
- ...(mcTab.liveTurnId !== null ? { turnId: mcTab.liveTurnId } : {}),
- });
- continue;
- }
- }
- rest.push(m);
- }
- if (consumed.length === 0) return msgs;
-
- // Mark the current assistant message as done streaming
- const result = rest.map((m) =>
- m.id === currentAssistantId ? { ...m, isStreaming: false } : m,
- );
-
- // Insert consumed messages right after the current assistant message
- let insertIdx = result.length;
- for (let i = result.length - 1; i >= 0; i--) {
- if (result[i]?.id === currentAssistantId) {
- insertIdx = i + 1;
- break;
- }
- }
- result.splice(insertIdx, 0, ...consumed);
- return result;
- });
- // Clear currentAssistantId so the next streaming event creates
- // a new assistant message block after the user's message
- updateTab(tabId, { currentAssistantId: null });
- break;
- }
- case "message-cancelled": {
- if (!tabId) break;
- const cancelEvent = event as AgentEvent & { tabId: string; messageId: string };
- const cancelTab = getTabById(tabId);
- if (!cancelTab) break;
- updateTab(tabId, {
- queuedMessages: cancelTab.queuedMessages.filter((m) => m.id !== cancelEvent.messageId),
- live: cancelTab.live.filter(
- (m) => !(m.role === "user" && m.id === `queued-${cancelEvent.messageId}`),
- ),
- });
- break;
- }
- case "compaction-started": {
- const ev = event as AgentEvent & { tempTabId: string; sourceTabId: string };
- // Lock the source tab's input while compaction runs.
- if (getTabById(ev.sourceTabId)) {
- updateTab(ev.sourceTabId, { isCompacting: true });
- }
- // Mark the placeholder tab so it shows the "compacting…" screen.
- if (getTabById(ev.tempTabId)) {
- updateTab(ev.tempTabId, { compactingSource: ev.sourceTabId });
- }
- break;
- }
- case "compaction-complete": {
- const ev = event as AgentEvent & {
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- };
- void finishCompaction(ev);
- break;
- }
- case "compaction-error": {
- const ev = event as AgentEvent & {
- tempTabId: string;
- sourceTabId: string;
- error: string;
- };
- if (getTabById(ev.sourceTabId)) {
- updateTab(ev.sourceTabId, { isCompacting: false });
- }
- // Surface the error on the placeholder tab (if still open) so the
- // user sees why it failed; the source conversation is untouched.
- const tmp = getTabById(ev.tempTabId);
- if (tmp) {
- updateTab(ev.tempTabId, {
- compactingSource: null,
- compactionError: ev.error,
- });
- }
- break;
- }
- }
- }
-
- async function autoCheckDefaultSkills(): Promise<void> {
- try {
- const res = await fetch(`${config.apiBase}/skills`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- skills?: Array<{
- name: string;
- scope: string;
- directory: string;
- }>;
- };
- const defaultSkills = (data.skills ?? []).filter((s) => s.directory === "default");
- if (defaultSkills.length === 0) return;
- const checks: Record<string, boolean> = { ...appSettings.skillChecks };
- for (const skill of defaultSkills) {
- checks[`${skill.scope}:${skill.name}`] = true;
- }
- appSettings.skillChecks = checks;
- } catch {
- // Silently ignore — skills will still be available for manual checking
- }
- }
-
- async function autoSelectDefaultAgent(tabId: string): Promise<void> {
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- agents?: Array<{
- slug: string;
- scope: string;
- name: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- cwd?: string;
- }>;
- };
- const agents = data.agents ?? [];
- const defaultAgent = agents.find(
- (a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global",
- );
- if (!defaultAgent) return;
-
- const tab = getTabById(tabId);
- if (!tab) return;
-
- // Apply the default agent
- const firstModel = defaultAgent.models[0];
- const patch: Partial<Tab> = {
- agentSlug: defaultAgent.slug,
- agentScope: defaultAgent.scope,
- agentModels: defaultAgent.models,
- workingDirectory: defaultAgent.cwd || null,
- };
- if (firstModel) {
- patch.keyId = firstModel.key_id;
- patch.modelId = firstModel.model_id;
- }
- updateTab(tabId, patch);
-
- // Merge the agent's skills into existing checked skills
- if (defaultAgent.skills.length > 0) {
- const checks: Record<string, boolean> = { ...appSettings.skillChecks };
- for (const skillKey of defaultAgent.skills) {
- checks[skillKey] = true;
- }
- appSettings.skillChecks = checks;
- }
-
- // Apply tool permissions
- const perms: Record<string, boolean> = {};
- for (const key of Object.keys(appSettings.toolPerms)) {
- perms[key] = false;
- }
- for (const tool of defaultAgent.tools) {
- perms[tool] = true;
- }
- appSettings.toolPerms = perms;
-
- // Persist to backend
- if (firstModel) {
- fetch(`${config.apiBase}/tabs/${tabId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: firstModel.key_id, modelId: firstModel.model_id }),
- }).catch(() => {});
- }
- } catch {
- // Silently ignore
- }
- }
-
- async function refreshAgentConfig(tabId: string): Promise<void> {
- const tab = getTabById(tabId);
- if (!tab?.agentSlug || !tab.agentScope) return;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- agents?: Array<{
- slug: string;
- scope: string;
- models: AgentModelEntry[];
- cwd?: string;
- }>;
- };
- const agents = data.agents ?? [];
- const freshAgent = agents.find((a) => a.slug === tab.agentSlug && a.scope === tab.agentScope);
- if (!freshAgent) return;
- const patch: Partial<Tab> = {
- agentModels: freshAgent.models,
- // NOTE: do not reset workingDirectory here. It is a per-tab user
- // setting (see setWorkingDirectory); refreshing agent config on
- // send must not clobber the directory the user chose for this tab.
- };
- // Preserve the user's current selection if it still exists in the
- // refreshed models list. Only fall back to the first model when the
- // current selection is no longer valid.
- const currentStillValid = freshAgent.models.some(
- (m) => m.key_id === tab.keyId && m.model_id === tab.modelId,
- );
- if (!currentStillValid) {
- const firstModel = freshAgent.models[0];
- if (firstModel) {
- patch.keyId = firstModel.key_id;
- patch.modelId = firstModel.model_id;
- } else {
- patch.keyId = null;
- patch.modelId = null;
- }
- }
- updateTab(tabId, patch);
- } catch {
- // Silently fall back to stale values
- }
- }
-
- async function fetchSkillContent(scope: string, name: string): Promise<string | null> {
- try {
- const res = await fetch(
- `${config.apiBase}/skills/${encodeURIComponent(name)}?scope=${scope}`,
- );
- if (!res.ok) return null;
- const data = (await res.json()) as { content?: string };
- return data.content ?? null;
- } catch {
- return null;
- }
- }
-
- async function sendMessage(text: string, content?: UserContentPart[]): Promise<void> {
- let tab = getActiveTab();
- if (!tab) return;
-
- // A real user message disables+resets the warming timer immediately, so
- // the genuine turn appends to the real history with NO throwaway turns
- // present (it lands on the warm cache). Warming re-arms when this turn
- // ends (see the `status` handler → cacheWarming.onTurnEnded).
- cacheWarming.onUserMessage(tab.id);
-
- // Refresh agent config to pick up any changes made in AgentBuilder
- if (tab.agentSlug && tab.agentScope) {
- await refreshAgentConfig(tab.id);
- tab = getActiveTab();
- if (!tab) return;
- }
-
- // Fetch content for checked skills and build the message to send.
- // `skillPrefix` (when non-empty) is prepended to BOTH the text projection
- // that gets persisted/rendered AND the multimodal content array, so an
- // image turn still carries the activated skills to the model.
- let skillPrefix = "";
- const checkedKeys = Object.entries(appSettings.skillChecks)
- .filter(([, v]) => v)
- .map(([k]) => k);
-
- if (checkedKeys.length > 0) {
- const skillSections: string[] = [];
- for (const key of checkedKeys) {
- const [scope, ...nameParts] = key.split(":");
- const name = nameParts.join(":");
- if (!scope || !name) continue;
- const skillContent = await fetchSkillContent(scope, name);
- if (skillContent) {
- skillSections.push(`<skill name="${name}">\n${skillContent}\n</skill>`);
- }
- }
- if (skillSections.length > 0) {
- skillPrefix = `[The following skills have been activated for this message]\n\n${skillSections.join("\n\n")}\n\n---\n\n`;
- }
-
- // Track injected skills on the tab
- const newInjected = [...new Set([...tab.injectedSkills, ...checkedKeys])];
- updateTab(tab.id, { injectedSkills: newInjected });
-
- // Clear all checks
- appSettings.skillChecks = {};
- }
-
- const messageToSend = `${skillPrefix}${text}`;
- // Prepend the skill prefix to the multimodal content as a leading text
- // part so the model sees the activated skills before the attachments.
- const contentToSend =
- content && skillPrefix ? [{ type: "text" as const, text: skillPrefix }, ...content] : content;
-
- const userMsg: ChatMessage = {
- id: generateId(),
- role: "user",
- chunks: [{ type: "text", text }],
- };
-
- // If the agent is currently running, we expect the POST to be queued.
- // Optimistically assign the queued- prefix and add to queuedMessages BEFORE
- // the POST so that the WS "message-queued" event (which may arrive before
- // the HTTP response) can match the existing chat message instead of creating
- // a duplicate.
- const isRunning = tab.agentStatus === "running";
- let queueId: string | null = null;
- if (isRunning) {
- queueId = generateId();
- userMsg.id = `queued-${queueId}`;
- // Pre-populate queuedMessages so WS event finds it immediately
- tab.queuedMessages = [
- ...tab.queuedMessages,
- { id: queueId, message: text, timestamp: Date.now() },
- ];
- }
-
- // Optimistically show the user's message in the live tail.
- updateTab(tab.id, { live: [...tab.live, userMsg] });
- // Generate a title from the first user message of an empty tab.
- const isFirstMessage = tab.chunks.length === 0 && tab.live.length === 0;
- if (!tab.manualTitle && (isFirstMessage || tab.title === "New Tab")) {
- const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text;
- updateTab(tab.id, { title: titleText });
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: titleText }),
- }).catch(() => {});
- }
-
- // Save settings to DB before sending (bakes in on send)
- const settingsSaves: Promise<unknown>[] = [];
-
- if (appSettings.systemPrompt !== appSettings.savedSystemPrompt) {
- appSettings.savedSystemPrompt = appSettings.systemPrompt;
- settingsSaves.push(
- fetch(`${config.apiBase}/tabs/settings/system_prompt`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: appSettings.systemPrompt }),
- }).catch(() => {}),
- );
- }
-
- if (appSettings.toolPermsDirty) {
- const perms = appSettings.toolPerms;
- appSettings.savedToolPerms = { ...perms };
- for (const [id, enabled] of Object.entries(perms)) {
- settingsSaves.push(
- fetch(`${config.apiBase}/tabs/settings/perm_${id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: enabled ? "allow" : "ask" }),
- }).catch(() => {}),
- );
- }
- }
-
- if (settingsSaves.length > 0) {
- await Promise.all(settingsSaves);
- }
-
- try {
- const res = await fetch(`${config.apiBase}/chat`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: tab.id,
- message: messageToSend,
- ...(contentToSend ? { content: contentToSend } : {}),
- ...(tab.keyId ? { keyId: tab.keyId } : {}),
- ...(tab.modelId ? { modelId: tab.modelId } : {}),
- ...(tab.agentModels ? { agentModels: tab.agentModels } : {}),
- reasoningEffort: tab.reasoningEffort,
- ...(tab.workingDirectory ? { workingDirectory: tab.workingDirectory } : {}),
- ...(queueId ? { queueId } : {}),
- }),
- });
- if (!res.ok) {
- const body = await res.text();
- // Rollback optimistic queued state on error
- if (queueId) {
- const currentTab = getTabById(tab.id);
- if (currentTab) {
- updateTab(tab.id, {
- queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId),
- });
- }
- updateLive(tab.id, (msgs) =>
- msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)),
- );
- }
- const errMsg: ChatMessage = {
- id: generateId(),
- role: "assistant",
- chunks: [
- {
- type: "error",
- message: `Failed to send message (HTTP ${res.status})`,
- statusCode: res.status,
- },
- ],
- isStreaming: false,
- debugInfo: makeDebugInfo({
- error: `HTTP ${res.status}`,
- httpStatus: res.status,
- httpBody: body,
- }),
- };
- updateTab(tab.id, { live: [...(getTabById(tab.id)?.live ?? []), errMsg] });
- } else {
- const responseData = (await res.json()) as { status: string; messageId?: string };
- if (responseData.status === "queued" && responseData.messageId) {
- // The backend confirmed the message was queued with the ID we sent (queueId).
- // If the message was already consumed before we got here (super-fast agent),
- // clean up the optimistic queued state.
- if (queueId && recentlyConsumedIds.has(queueId)) {
- recentlyConsumedIds.delete(queueId);
- // queuedMessages and the queued- prefix have already been cleaned up by
- // the message-consumed handler. Nothing more to do.
- }
- // Otherwise everything is already in place from the optimistic update above.
- } else if (responseData.status === "ok") {
- // Agent wasn't running after all — undo the optimistic queued state if we set it.
- if (queueId) {
- const currentTab = getTabById(tab.id);
- if (currentTab) {
- updateTab(tab.id, {
- queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId),
- });
- }
- // Restore the message to a normal (non-queued) ID
- updateLive(tab.id, (msgs) =>
- msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)),
- );
- }
- }
- }
- } catch (err) {
- // Rollback optimistic queued state on network error
- if (queueId) {
- const currentTab = getTabById(tab.id);
- if (currentTab) {
- updateTab(tab.id, {
- queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId),
- });
- }
- updateLive(tab.id, (msgs) =>
- msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)),
- );
- }
- const errMsg: ChatMessage = {
- id: generateId(),
- role: "assistant",
- chunks: [{ type: "error", message: "Could not reach the server" }],
- isStreaming: false,
- debugInfo: makeDebugInfo({ error: err instanceof Error ? err.message : String(err) }),
- };
- updateTab(tab.id, { live: [...(getTabById(tab.id)?.live ?? []), errMsg] });
- }
- }
-
- function changeModel(keyId: string, modelId: string): void {
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { keyId, modelId });
-
- // Persist to backend
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId, modelId }),
- }).catch(() => {});
- }
-
- function setKey(keyId: string): void {
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { keyId, modelId: null });
-
- // Persist to backend
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId, modelId: null }),
- }).catch(() => {});
- }
-
- /**
- * Update the per-tab reasoning-effort selector. Ignores unrecognised
- * values so an out-of-range string from the UI can't corrupt the tab
- * state. This is the per-tab effort in the per-model → per-tab → default
- * resolution chain.
- */
- function setReasoningEffort(effort: string): void {
- if (!isReasoningEffort(effort)) return;
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { reasoningEffort: effort });
- }
-
- function setWorkingDirectory(dir: string | null): void {
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { workingDirectory: dir || null });
- }
-
- /**
- * Enable/disable prompt-cache warming for a tab (persisted per-tab). The
- * warming store arms or cancels its 4-minute idle timer accordingly.
- */
- function setCacheWarmingEnabled(tabId: string, enabled: boolean): void {
- cacheWarming.setEnabled(tabId, enabled);
- }
-
- function setAgent(
- agent: {
- slug: string;
- scope: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- cwd?: string;
- } | null,
- ): void {
- const tab = getActiveTab();
- if (!tab) return;
-
- if (!agent) {
- // Switch back to manual mode — clear agent and reset working directory
- updateTab(tab.id, {
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- });
- return;
- }
-
- // Apply agent's first model as the active key+model
- const firstModel = agent.models[0];
- const patch: Partial<Tab> = {
- agentSlug: agent.slug,
- agentScope: agent.scope,
- agentModels: agent.models,
- workingDirectory: agent.cwd || null,
- };
- if (firstModel) {
- patch.keyId = firstModel.key_id;
- patch.modelId = firstModel.model_id;
- }
- updateTab(tab.id, patch);
-
- // Reset and apply the agent's skills (don't accumulate from previous agents)
- const checks: Record<string, boolean> = {};
- for (const skillKey of agent.skills) {
- checks[skillKey] = true;
- }
- appSettings.skillChecks = checks;
-
- // Always reset tool permissions to agent's allowlist (even if empty)
- const perms: Record<string, boolean> = {};
- for (const key of Object.keys(appSettings.toolPerms)) {
- perms[key] = false;
- }
- for (const tool of agent.tools) {
- perms[tool] = true;
- }
- appSettings.toolPerms = perms;
-
- // Persist to backend
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- ...(firstModel ? { keyId: firstModel.key_id, modelId: firstModel.model_id } : {}),
- }),
- }).catch(() => {});
- }
-
- function replyPermission(id: string, reply: "once" | "always" | "reject"): void {
- if (wsClient.connectionStatus !== "connected") return;
- const prompt = pendingPermissions.find((p) => p.id === id);
- wsClient.send({ type: "permission-reply", id, reply });
- pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
- if (prompt) {
- permissionLog = [
- ...permissionLog,
- {
- id: generateId(),
- permission: prompt.permission,
- patterns: prompt.patterns,
- action: reply,
- timestamp: new Date().toISOString(),
- description: prompt.description,
- },
- ];
- }
- }
-
- async function cancelQueuedMessage(tabId: string, messageId: string): Promise<void> {
- const tab = getTabById(tabId);
- if (tab) {
- updateTab(tabId, {
- queuedMessages: tab.queuedMessages.filter((m) => m.id !== messageId),
- live: tab.live.filter((m) => !(m.role === "user" && m.id === `queued-${messageId}`)),
- });
- }
- try {
- await fetch(`${config.apiBase}/chat/cancel`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId, messageId }),
- });
- } catch {
- // ignore
- }
- }
-
- async function stopGeneration(tabId: string): Promise<void> {
- try {
- await fetch(`${config.apiBase}/chat/stop`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId }),
- });
- } catch {
- // ignore
- }
- }
-
- function copyConversation(): string {
- const tab = getActiveTab();
- if (!tab) return "";
-
- const enabledTools = Object.entries(appSettings.savedToolPerms)
- .filter(([, v]) => v)
- .map(([k]) => k);
-
- // Short, distinguishable chunk descriptor — lets us see the shape of
- // each message at a glance without dumping the full content.
- // E.g. `chunks=6: thinking, tool-batch[2], thinking, tool-batch[1], thinking, text`.
- // If a message reports `chunks=0`, the in-memory store has no content
- // for it — which is the canonical symptom of a wire-format / load
- // failure. Always include this so bug reports are diagnosable from
- // the paste alone, without DB access.
- const summarizeChunks = (chunks: (typeof tab.renderGroups)[number]["chunks"]) => {
- if (chunks.length === 0) return "chunks=0";
- const parts = chunks.map((c) => {
- if (c.type === "tool-batch") return `tool-batch[${c.calls.length}]`;
- if (c.type === "system") return `system:${c.kind}`;
- return c.type; // text | thinking | error
- });
- return `chunks=${chunks.length}: ${parts.join(", ")}`;
- };
-
- const shortId = (id: string | undefined) => (id ? `${id.slice(0, 8)}…` : "?");
-
- const lines: string[] = [
- "=== Dispatch Conversation ===",
- `Tab ID: ${tab.id}`,
- `Tab: ${tab.title}`,
- `Model: ${tab.modelId ?? "default"}`,
- `Tools: ${enabledTools.length > 0 ? enabledTools.join(", ") : "none"}`,
- `Injected Skills: ${tab.injectedSkills.length > 0 ? tab.injectedSkills.join(", ") : "none"}`,
- `Total tabs: ${tabs.length}`,
- `All tab IDs: ${tabs.map((t) => t.id).join(", ")}`,
- "",
- // Store-level state — distinguishes "store empty (load/parse
- // failure)" from "store populated, agent stuck mid-stream" from
- // "agent finished cleanly" etc.
- "--- Frontend store state ---",
- `Connected to backend: ${isConnected}`,
- `Tab agentStatus: ${tab.agentStatus}`,
- `Tab currentAssistantId: ${tab.currentAssistantId ?? "null"}`,
- `Render groups in store: ${tab.renderGroups.length}`,
- `Queued messages: ${tab.queuedMessages.length}`,
- `Persistent: ${tab.persistent}`,
- `Working directory: ${tab.workingDirectory ?? "default"}`,
- `Reasoning effort: ${tab.reasoningEffort}`,
- `Todos: ${tab.tasks.length} (${tab.tasks.filter((t) => t.status === "completed").length} completed)`,
- "",
- ];
- const TOOL_RESULT_MAX = 300;
-
- for (const msg of tab.renderGroups) {
- const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant";
- const streamingFlag = msg.isStreaming ? ", streaming=true" : "";
- // Inline message diagnostics — id, streaming, chunk summary —
- // makes wire-format / store-shape bugs immediately visible.
- lines.push(
- `--- ${role} --- (id=${shortId(msg.id)}${streamingFlag}, ${summarizeChunks(msg.chunks)})`,
- );
-
- // Surface non-trivial debugInfo (errors, HTTP failures). Skip
- // when there's nothing interesting — keeps the output readable
- // for happy-path conversations.
- const dbg = msg.debugInfo;
- if (dbg && (dbg.error || dbg.httpStatus !== undefined || dbg.httpBody)) {
- const dbgBits: string[] = [];
- if (dbg.error) dbgBits.push(`error="${dbg.error}"`);
- if (dbg.httpStatus !== undefined) dbgBits.push(`httpStatus=${dbg.httpStatus}`);
- if (dbg.httpBody) {
- const body = dbg.httpBody.length > 200 ? `${dbg.httpBody.slice(0, 200)}…` : dbg.httpBody;
- dbgBits.push(`httpBody="${body}"`);
- }
- lines.push(` [debug]: ${dbgBits.join(" ")}`);
- }
-
- for (const chunk of msg.chunks) {
- switch (chunk.type) {
- case "text":
- lines.push(chunk.text);
- break;
- case "thinking":
- lines.push(` [Thinking]: ${chunk.text}`);
- break;
- case "tool-batch":
- for (const call of chunk.calls) {
- lines.push(` [Tool: ${call.name}]`);
- if (call.result !== undefined) {
- const result = String(call.result);
- if (result.length > TOOL_RESULT_MAX) {
- lines.push(
- ` Result: ${result.slice(0, TOOL_RESULT_MAX)}... [truncated, ${result.length} chars total]`,
- );
- } else {
- lines.push(` Result: ${result}`);
- }
- }
- }
- break;
- case "error": {
- const code = chunk.statusCode !== undefined ? ` (HTTP ${chunk.statusCode})` : "";
- lines.push(` [Error${code}]: ${chunk.message}`);
- break;
- }
- case "system":
- lines.push(` [${chunk.kind}]: ${chunk.text}`);
- break;
- }
- }
- lines.push("");
- }
- return lines.join("\n");
- }
-
- return {
- get tabs() {
- return tabs;
- },
- get activeTabId() {
- return activeTabId;
- },
- get activeTab() {
- return getActiveTab();
- },
- get isConnected() {
- return isConnected;
- },
- get pendingPermissions() {
- return pendingPermissions;
- },
- get permissionLog() {
- return permissionLog;
- },
- get configReloaded() {
- return configReloaded;
- },
- shortHandleFor,
- createNewTab,
- switchTab,
- closeTab,
- renameTab,
- reorderTabs,
- setDraft,
- addAttachment,
- sendMessage,
- cancelQueuedMessage,
- stopGeneration,
- changeModel,
- setKey,
- setReasoningEffort,
- setAgent,
- replyPermission,
- copyConversation,
- promoteTab,
- openAgentTab,
- setWorkingDirectory,
- setCacheWarmingEnabled,
- startCompaction,
- // Exposed so tests can drive the real reactive code path that the
- // WS callback uses in production. Not intended for use in
- // components — they should rely on the WS subscription instead.
- handleEvent,
- hydrateFromBackend,
- loadOlderChunks,
- evictChunks,
- setScrolledUp,
- };
-}
-
-export const tabStore = createTabStore();
diff --git a/packages/frontend/src/lib/theme.ts b/packages/frontend/src/lib/theme.ts
deleted file mode 100644
index 2b4bad0..0000000
--- a/packages/frontend/src/lib/theme.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * Single source of truth for the app's theme picker.
- *
- * Two callers care about themes:
- * - `App.svelte`'s `onMount`, which applies the persisted theme on boot
- * so the first paint is the right color.
- * - `SettingsPanel.svelte`'s theme `<select>`, which lets the user
- * change theme at runtime.
- *
- * Both used to hand-roll their own `localStorage` key, default value,
- * and DOM-attribute write. That drift produced a real bug: `App.svelte`
- * left the DOM untouched on first load (so daisyUI fell back to the
- * first theme in `app.css`, `light`), while `SettingsPanel` showed
- * `"dark"` as the selected value — UI and reality disagreed until the
- * user manually picked a theme. Centralizing here closes that gap.
- *
- * The theme list also intentionally mirrors the `@plugin "daisyui"`
- * block in `app.css`. Drift between the two is harmless (a theme name
- * present here but not in CSS just falls back to the default daisyUI
- * theme at render time), but they should be kept in sync by hand —
- * daisyUI's plugin config is a CSS-time concern and can't be imported
- * from TS.
- */
-
-export const THEMES = [
- "light",
- "dark",
- "dracula",
- "night",
- "nord",
- "sunset",
- "cyberpunk",
- "forest",
- "cmyk",
- "coffee",
- "caramellatte",
- "garden",
- "luxury",
-] as const;
-
-export type Theme = (typeof THEMES)[number];
-
-export const THEME_STORAGE_KEY = "dispatch-theme";
-
-/**
- * The fallback theme used both at first-boot apply (when nothing is
- * persisted) and as the UI's default-selected value. They MUST match —
- * if they ever diverged again, the UI would show one theme and the
- * page would render another.
- */
-export const DEFAULT_THEME: Theme = "dark";
-
-/**
- * Read the persisted theme. Returns `DEFAULT_THEME` when nothing is
- * stored, when access throws (private mode / SecurityError), or when
- * the stored value isn't one of the known themes. Never throws.
- *
- * SSR-safe: if `localStorage` is undefined (e.g. `vite build` during
- * prerender, if that's ever wired up), returns the default.
- */
-export function loadStoredTheme(): Theme {
- try {
- if (typeof localStorage === "undefined") return DEFAULT_THEME;
- const raw = localStorage.getItem(THEME_STORAGE_KEY);
- if (raw && (THEMES as readonly string[]).includes(raw)) {
- return raw as Theme;
- }
- return DEFAULT_THEME;
- } catch {
- return DEFAULT_THEME;
- }
-}
-
-/**
- * Apply a theme to the live document and persist it. The DOM write is
- * unconditional (daisyUI keys off `data-theme` on the `<html>`
- * element); the storage write is best-effort and swallows quota /
- * SecurityError failures because the session continues to work either
- * way — only cross-reload persistence degrades.
- */
-export function applyTheme(theme: Theme): void {
- if (typeof document !== "undefined") {
- document.documentElement.setAttribute("data-theme", theme);
- }
- try {
- if (typeof localStorage !== "undefined") {
- localStorage.setItem(THEME_STORAGE_KEY, theme);
- }
- } catch {
- // Best-effort.
- }
-}
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
deleted file mode 100644
index bb7c169..0000000
--- a/packages/frontend/src/lib/types.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-export interface DebugInfo {
- timestamp: string;
- error?: string;
- notice?: string;
- model?: string;
- apiBase?: string;
- connectionStatus?: string;
- agentStatus?: string;
- rawEvent?: unknown;
- httpStatus?: number;
- httpBody?: string;
-}
-
-/**
- * Per-tab prompt-cache telemetry, accumulated from the `usage` AgentEvent
- * (one per LLM round-trip). Token counts are cumulative across the session
- * since the page loaded; `last` holds the most recent request's split. Powers
- * the "Cache Rate" sidebar view. The cache hit rate is
- * `cacheReadTokens / inputTokens` (inputTokens is the TOTAL prompt, including
- * cached tokens).
- */
-export interface CacheStats {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- /** Number of LLM requests (usage events) counted. */
- requests: number;
- last: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- } | null;
-}
-
-/**
- * Mirror of the core `Chunk` union (see packages/core/src/types/index.ts).
- *
- * Wire-format symmetry MUST be kept with core. If you change one, change
- * the other. The frontend store calls into the shared
- * `appendEventToChunks` helper from core so the two stay in lockstep.
- */
-export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk;
-
-export interface TextChunk {
- type: "text";
- text: string;
-}
-
-export interface ThinkingChunk {
- type: "thinking";
- text: string;
- /**
- * Mirror of core. Anthropic's `providerMetadata` blob captured from
- * the v6 `reasoning-end` stream event. Present once the backend has
- * sealed the chunk; absent for in-flight thinking or for non-Anthropic
- * models. The UI doesn't render this — it lives here for wire-format
- * symmetry with the persisted chunk shape.
- */
- metadata?: Record<string, unknown>;
-}
-
-export interface ToolBatchChunk {
- type: "tool-batch";
- calls: ToolBatchEntry[];
-}
-
-export interface ToolBatchEntry {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-
-export interface ErrorChunk {
- type: "error";
- message: string;
- statusCode?: number;
-}
-
-export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
-
-export interface SystemChunk {
- type: "system";
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * A render bubble. NOT stored state — it's a derived projection of the flat
- * chunk log (`groupRowsToMessages(tab.chunks)`) concatenated with the transient
- * live tail. The store's source of truth for history is `tab.chunks: ChunkRow[]`.
- */
-export interface ChatMessage {
- id: string;
- role: "user" | "assistant" | "system";
- chunks: Chunk[];
- isStreaming?: boolean;
- debugInfo?: DebugInfo;
- seq?: number;
- /**
- * turn_id of the chunk rows this message was grouped from (or the in-flight
- * turn for a live message). Gives a stable, turn-scoped render key so the
- * bubble doesn't remount when the live turn reconciles into sealed chunks.
- */
- turnId?: string;
-}
-
-export type ConnectionStatus = "connecting" | "connected" | "disconnected";
-
-/**
- * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts).
- *
- * Sent on every WS (re)connect and via `GET /status`. The frontend uses
- * this to:
- * - reconcile its in-memory `agentStatus` with the backend's truth
- * after a disconnect window;
- * - reconstruct the in-flight assistant message for any tab the
- * backend is currently streaming, so the user sees the partial
- * thinking / text without waiting for the next delta.
- *
- * Wire-format symmetry MUST be kept with core. If you change one,
- * change the other.
- */
-export interface TabStatusSnapshot {
- status: "idle" | "running" | "error";
- currentChunks?: Chunk[];
- currentAssistantId?: string;
- /** turn_id of the in-flight turn; present iff status === "running". */
- currentTurnId?: string;
- /** The tab's todo list, for rehydrating the Tasks panel on reload. */
- tasks?: TaskItem[];
-}
-
-export type AgentEvent =
- | { type: "status"; status: "idle" | "running" | "error" }
- // Opens a turn before any content delta; carries the turn_id used to tag
- // the live chunks so they reconcile cleanly when the turn seals.
- | { type: "turn-start"; turnId: string }
- // Fires after the turn settled AND its chunks were persisted (after the DB
- // write, post status:idle). Triggers the frontend's reconcile-from-DB.
- // `usageStats` carries the tab's authoritative usage aggregate (read after the
- // usage rows were persisted); the store REPLACES `cacheStats` with it,
- // reconciling the live accumulator to the DB truth (self-heals the live
- // overshoot from a discarded rate-limited fallback attempt). null ⇒ no usage
- // rows; absent ⇒ leave cacheStats untouched.
- | { type: "turn-sealed"; turnId: string; usageStats?: CacheStats | null }
- // Sent on every WS (re)connect: a snapshot of every tab the backend is
- // currently tracking and its live status. The frontend uses this to
- // detect desync after a reconnect (e.g. bun --watch restart killed the
- // in-flight agent state, frontend missed `done` / `status:idle` events).
- | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> }
- | { type: "text-delta"; delta: string }
- | { type: "reasoning-delta"; delta: string }
- | { type: "reasoning-end"; metadata?: Record<string, unknown> }
- | {
- type: "tool-call";
- toolCall: {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- };
- }
- | {
- type: "tool-result";
- toolResult: { toolCallId: string; result: string; isError: boolean };
- }
- | {
- type: "usage";
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- };
- }
- | { type: "error"; error: string }
- | { type: "notice"; message: string }
- | { type: "model-changed"; keyId: string; modelId: string }
- | { type: "task-list-update"; tasks: TaskItem[] }
- | { type: "config-reload" }
- | {
- type: "done";
- message: {
- role: string;
- content: string;
- toolCalls?: unknown[];
- toolResults?: unknown[];
- };
- }
- | { type: "permission-prompt"; pending: PermissionPrompt[] }
- | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- | {
- type: "tab-created";
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory?: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
- }
- | { type: "message-queued"; tabId: string; messageId: string; message: string }
- | {
- type: "message-consumed";
- tabId: string;
- messageIds: string[];
- /**
- * Why the queue was drained:
- * - "interrupt": consumed mid-turn, folded into a running turn's tool
- * result as a [USER INTERRUPT]. The optimistic bubble collapses into
- * that sealed turn.
- * - "continuation": consumed between turns to START a new turn. The
- * optimistic bubble becomes that new turn's initiating user row.
- * Absent ⇒ treat as "interrupt" (back-compat).
- */
- reason?: "interrupt" | "continuation";
- }
- | { type: "message-cancelled"; tabId: string; messageId: string }
- | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
- | {
- type: "compaction-complete";
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }
- | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
-
-export interface TaskItem {
- /** Stable positional id for Svelte keying only; never shown to the model. */
- id: string;
- content: string;
- status: "pending" | "in_progress" | "completed" | "cancelled";
-}
-
-export interface PermissionPrompt {
- id: string;
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-export interface ModelOverride {
- keyId: string;
- modelId: string;
-}
-
-export interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
-}
-
-export interface QueuedMessage {
- id: string;
- message: string;
- timestamp: number;
-}
-
-export interface LogEntry {
- id: string;
- permission: string;
- patterns: string[];
- action: "once" | "always" | "reject";
- timestamp: string;
- description: string;
-}
-
-export interface UsageBucket {
- utilization?: number;
- resetsAt?: number;
-}
-
-export interface ClaudeAccountUsage {
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
-}
-
-export interface ClaudeUsageData {
- provider: "anthropic";
- accounts?: ClaudeAccountUsage[];
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
-}
-
-export interface OpencodeUsageData {
- provider: "opencode-go";
- unavailable?: boolean;
- consoleUrl?: string;
- limits?: { fiveHour?: string; weekly?: string; monthly?: string };
- fiveHour?: UsageBucket;
- weekly?: UsageBucket;
- monthly?: UsageBucket;
-}
-
-export interface CopilotUsageData {
- provider: "github-copilot";
- tokensConsumed?: number;
- tokensRemaining?: number;
- percentUsed?: number;
- resetAt?: number;
- plan?: string;
-}
-
-export interface GoogleUsageData {
- provider: "google";
- models?: Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
- }>;
- currentUsage?: {
- percentUsed: number;
- resetsAt?: string;
- };
- weeklyUsage?: {
- percentUsed: number;
- resetsAt?: string;
- };
-}
-
-export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData | GoogleUsageData;
diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts
deleted file mode 100644
index ff142ad..0000000
--- a/packages/frontend/src/lib/ws.svelte.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { config } from "./config.js";
-import type { AgentEvent, ConnectionStatus } from "./types.js";
-
-type EventCallback = (event: AgentEvent) => void;
-
-// Close any stale WebSocket from HMR reloads
-if (import.meta.hot) {
- import.meta.hot.dispose((data: Record<string, unknown>) => {
- const old = data._ws as WebSocket | undefined;
- if (old && old.readyState === WebSocket.OPEN) {
- old.close();
- }
- });
-}
-
-function createWebSocketClient(url: string) {
- let connectionStatus: ConnectionStatus = $state("disconnected");
- let ws: WebSocket | null = null;
- let reconnectDelay = 1000;
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
- let manualDisconnect = false;
- const callbacks = new Set<EventCallback>();
-
- function connect() {
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
- return;
- }
- manualDisconnect = false;
- connectionStatus = "connecting";
- ws = new WebSocket(url);
-
- // Store ref for HMR cleanup
- if (import.meta.hot) {
- import.meta.hot.data._ws = ws;
- }
-
- ws.onopen = () => {
- connectionStatus = "connected";
- reconnectDelay = 1000;
- };
-
- ws.onmessage = (event: MessageEvent) => {
- let data: AgentEvent;
- try {
- data = JSON.parse(event.data as string) as AgentEvent;
- } catch (err) {
- // Genuinely malformed WS payload — these are rare and harmless.
- console.warn("[ws] ignored malformed message:", err);
- return;
- }
- // Run callbacks OUTSIDE the parse try so callback throws are visible.
- // The previous catch-everything wrapper silently swallowed bugs in
- // downstream handlers (notably the `structuredClone` of a Svelte 5
- // $state proxy throwing DataCloneError), making them undiagnosable.
- for (const cb of callbacks) {
- try {
- cb(data);
- } catch (err) {
- console.error("[ws] callback threw for event:", data, err);
- }
- }
- };
-
- ws.onclose = () => {
- connectionStatus = "disconnected";
- ws = null;
- if (!manualDisconnect) {
- scheduleReconnect();
- }
- };
-
- ws.onerror = () => {
- ws?.close();
- };
- }
-
- function scheduleReconnect() {
- if (reconnectTimer) clearTimeout(reconnectTimer);
- reconnectTimer = setTimeout(() => {
- reconnectTimer = null;
- connect();
- }, reconnectDelay);
- reconnectDelay = Math.min(reconnectDelay * 2, 10000);
- }
-
- function disconnect() {
- manualDisconnect = true;
- if (reconnectTimer) {
- clearTimeout(reconnectTimer);
- reconnectTimer = null;
- }
- ws?.close();
- ws = null;
- connectionStatus = "disconnected";
- }
-
- function onEvent(callback: EventCallback) {
- callbacks.add(callback);
- return () => {
- callbacks.delete(callback);
- };
- }
-
- /** Remove all registered event callbacks (used for HMR safety). */
- function clearCallbacks() {
- callbacks.clear();
- }
-
- function send(data: unknown): void {
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(data));
- }
- }
-
- return {
- get connectionStatus() {
- return connectionStatus;
- },
- connect,
- disconnect,
- onEvent,
- clearCallbacks,
- send,
- };
-}
-
-export const wsClient = createWebSocketClient(config.wsUrl);
diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts
deleted file mode 100644
index fd54362..0000000
--- a/packages/frontend/src/main.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import App from "./App.svelte";
-import "./app.css";
-import { mount } from "svelte";
-
-const app = mount(App, {
- target: document.getElementById("app") as HTMLElement,
-});
-
-export default app;
diff --git a/packages/frontend/src/vite-env.d.ts b/packages/frontend/src/vite-env.d.ts
deleted file mode 100644
index 4078e74..0000000
--- a/packages/frontend/src/vite-env.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-/// <reference types="svelte" />
-/// <reference types="vite/client" />
diff --git a/packages/frontend/svelte.config.js b/packages/frontend/svelte.config.js
deleted file mode 100644
index f77d881..0000000
--- a/packages/frontend/svelte.config.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
-
-export default {
- preprocess: vitePreprocess(),
-};
diff --git a/packages/frontend/tests/attachment-tokens.test.ts b/packages/frontend/tests/attachment-tokens.test.ts
deleted file mode 100644
index 7208cf3..0000000
--- a/packages/frontend/tests/attachment-tokens.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- computeTokenDeletion,
- findTokens,
- generateTokenId,
- intactTokenIds,
- makeAttachmentToken,
- markerFor,
- parseDraft,
- type StagedAttachment,
-} from "../src/lib/attachment-tokens.js";
-
-function img(id: string): StagedAttachment {
- return { id, kind: "image", mediaType: "image/png", data: "QQ==" };
-}
-function pdf(id: string): StagedAttachment {
- return { id, kind: "pdf", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" };
-}
-
-describe("token helpers", () => {
- it("round-trips make/find", () => {
- const tok = makeAttachmentToken("image", "abc123");
- expect(tok).toBe("【image:abc123】");
- const found = findTokens(`x ${tok} y`);
- expect(found).toHaveLength(1);
- expect(found[0]).toMatchObject({ id: "abc123", kind: "image", start: 2, end: 2 + tok.length });
- });
-
- it("generates 6-char lowercase-alnum ids", () => {
- for (let i = 0; i < 20; i++) {
- expect(generateTokenId()).toMatch(/^[a-z0-9]{6}$/);
- }
- });
-
- it("finds multiple tokens in order and reports intact ids", () => {
- const text = `a ${makeAttachmentToken("image", "aaaaaa")} b ${makeAttachmentToken("pdf", "bbbbbb")}`;
- const found = findTokens(text);
- expect(found.map((t) => t.id)).toEqual(["aaaaaa", "bbbbbb"]);
- expect(intactTokenIds(text)).toEqual(new Set(["aaaaaa", "bbbbbb"]));
- });
-
- it("does not treat a partially-broken token as intact", () => {
- // Missing closing bracket → not a valid token.
- expect(intactTokenIds("【image:aaaaaa").size).toBe(0);
- });
-});
-
-describe("computeTokenDeletion", () => {
- const tok = makeAttachmentToken("image", "abcabc");
- const text = `hi ${tok}!`; // token spans indices 3..3+len
- const tokStart = 3;
- const tokEnd = 3 + tok.length;
-
- it("returns null when no tokens exist", () => {
- expect(computeTokenDeletion("plain", 2, 2, "Backspace")).toBeNull();
- });
-
- it("Backspace just after a token removes the whole token atomically", () => {
- const res = computeTokenDeletion(text, tokEnd, tokEnd, "Backspace");
- expect(res).not.toBeNull();
- expect(res?.text).toBe("hi !");
- expect(res?.caret).toBe(tokStart);
- expect(res?.removedIds).toEqual(["abcabc"]);
- });
-
- it("Delete just before a token removes the whole token atomically", () => {
- const res = computeTokenDeletion(text, tokStart, tokStart, "Delete");
- expect(res?.text).toBe("hi !");
- expect(res?.caret).toBe(tokStart);
- expect(res?.removedIds).toEqual(["abcabc"]);
- });
-
- it("Backspace NOT adjacent to a token returns null (default editing)", () => {
- // Caret at index 2 (after "hi"), token is further along.
- expect(computeTokenDeletion(text, 2, 2, "Backspace")).toBeNull();
- });
-
- it("a selection overlapping a token expands to cover the whole token", () => {
- // Select from inside "hi " through the middle of the token.
- const res = computeTokenDeletion(text, 1, tokStart + 3, "Backspace");
- expect(res).not.toBeNull();
- // Deletion starts at min(selStart, tokStart)=1 and ends at tokEnd.
- expect(res?.text).toBe("h!");
- expect(res?.removedIds).toEqual(["abcabc"]);
- });
-
- it("a range selection touching no token returns null", () => {
- expect(computeTokenDeletion(text, 0, 2, "Backspace")).toBeNull();
- });
-});
-
-describe("parseDraft", () => {
- it("returns plain text + null content when there are no attachments", () => {
- const res = parseDraft("just text", new Map());
- expect(res.displayText).toBe("just text");
- expect(res.content).toBeNull();
- });
-
- it("interleaves text and attachment parts in order", () => {
- const a = img("aaaaaa");
- const b = pdf("bbbbbb");
- const map = new Map([
- [a.id, a],
- [b.id, b],
- ]);
- const draft = `A: ${makeAttachmentToken("image", a.id)} B: ${makeAttachmentToken("pdf", b.id)} end`;
- const res = parseDraft(draft, map);
-
- // displayText swaps tokens for markers.
- expect(res.displayText).toBe(`A: ${markerFor("image")} B: ${markerFor("pdf")} end`);
-
- // content interleaves the surrounding text with the attachment parts.
- expect(res.content).toEqual([
- { type: "text", text: "A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- { type: "text", text: " B: " },
- { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
- { type: "text", text: " end" },
- ]);
- });
-
- it("treats an orphan token (no staged attachment) as plain text", () => {
- // Token present in text but not in the attachments map.
- const draft = `x ${makeAttachmentToken("image", "zzzzzz")} y`;
- const res = parseDraft(draft, new Map());
- expect(res.displayText).toBe(`x ${markerFor("image")} y`);
- // No real attachment → null content (plain-text send).
- expect(res.content).toBeNull();
- });
-});
diff --git a/packages/frontend/tests/cache-warm-storage.test.ts b/packages/frontend/tests/cache-warm-storage.test.ts
deleted file mode 100644
index 458d07d..0000000
--- a/packages/frontend/tests/cache-warm-storage.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Tests for `src/lib/cache-warm-storage.ts` — the per-tab localStorage
- * round-trip for the "keep prompt cache warm" toggle.
- *
- * As in `sidebar-storage.test.ts`, Bun's `localStorage` shim is partial, so we
- * install a clean in-memory polyfill per-test.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import {
- clearCacheWarmEnabled,
- loadCacheWarmEnabled,
- saveCacheWarmEnabled,
-} from "../src/lib/cache-warm-storage.js";
-
-const LS_KEY = "dispatch-cache-warming";
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-beforeEach(() => {
- vi.stubGlobal("localStorage", makeLocalStorageMock());
-});
-
-describe("loadCacheWarmEnabled", () => {
- it("defaults to false when nothing is stored", () => {
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("returns true after enabling a tab", () => {
- saveCacheWarmEnabled("tab-1", true);
- expect(loadCacheWarmEnabled("tab-1")).toBe(true);
- });
-
- it("is scoped per-tab (one tab's flag doesn't leak to another)", () => {
- saveCacheWarmEnabled("tab-1", true);
- expect(loadCacheWarmEnabled("tab-2")).toBe(false);
- });
-
- it("returns false when the stored JSON is malformed", () => {
- localStorage.setItem(LS_KEY, "not json {[");
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("ignores non-boolean entries", () => {
- localStorage.setItem(LS_KEY, JSON.stringify({ "tab-1": "yes", "tab-2": true }));
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- expect(loadCacheWarmEnabled("tab-2")).toBe(true);
- });
-});
-
-describe("saveCacheWarmEnabled", () => {
- it("removes the entry when set to false (default-off representation)", () => {
- saveCacheWarmEnabled("tab-1", true);
- saveCacheWarmEnabled("tab-1", false);
- const raw = localStorage.getItem(LS_KEY);
- expect(raw).toBe(JSON.stringify({}));
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("preserves other tabs' flags when toggling one", () => {
- saveCacheWarmEnabled("tab-1", true);
- saveCacheWarmEnabled("tab-2", true);
- saveCacheWarmEnabled("tab-1", false);
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- expect(loadCacheWarmEnabled("tab-2")).toBe(true);
- });
-
- it("silently ignores storage write errors", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(() => saveCacheWarmEnabled("tab-1", true)).not.toThrow();
- });
-});
-
-describe("clearCacheWarmEnabled", () => {
- it("drops a tab's persisted flag", () => {
- saveCacheWarmEnabled("tab-1", true);
- clearCacheWarmEnabled("tab-1");
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("is a no-op when the tab has no stored flag", () => {
- expect(() => clearCacheWarmEnabled("tab-x")).not.toThrow();
- expect(localStorage.getItem(LS_KEY)).toBeNull();
- });
-});
diff --git a/packages/frontend/tests/cache-warming.test.ts b/packages/frontend/tests/cache-warming.test.ts
deleted file mode 100644
index 583d563..0000000
--- a/packages/frontend/tests/cache-warming.test.ts
+++ /dev/null
@@ -1,232 +0,0 @@
-/**
- * Tests for `src/lib/cache-warming.svelte.ts` — the prompt-cache warming
- * timer/orchestration store.
- *
- * Drives the REAL `$state`-backed store via `createCacheWarmingStore()` under
- * fake timers, asserting the idle/turn/send lifecycle, the repeating 4-minute
- * cadence, the warming-specific last-% capture, and error surfacing.
- */
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// `config.ts` reads localStorage at import; mock it (mirrors chat-store.test).
-vi.mock("../src/lib/config.js", () => ({
- config: {
- apiBase: "http://test.local:3000",
- wsUrl: "ws://test.local:3000/ws",
- defaultApiBase: "http://test.local:3000",
- setApiBase: vi.fn(),
- },
-}));
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-import { createCacheWarmingStore, WARM_INTERVAL_MS } from "../src/lib/cache-warming.svelte.js";
-
-type WarmStore = ReturnType<typeof createCacheWarmingStore>;
-
-function makeFetchOk(usage: { inputTokens: number; cacheReadTokens: number }): typeof fetch {
- return vi.fn(() =>
- Promise.resolve({
- ok: true,
- status: 200,
- json: () => Promise.resolve({ usage }),
- }),
- ) as unknown as typeof fetch;
-}
-
-/** Flush microtasks so awaited fetch `.json()` chains settle under fake timers. */
-async function flush(): Promise<void> {
- await Promise.resolve();
- await Promise.resolve();
- await Promise.resolve();
-}
-
-let store: WarmStore;
-
-beforeEach(() => {
- vi.useFakeTimers();
- vi.stubGlobal("localStorage", makeLocalStorageMock());
- store = createCacheWarmingStore();
-});
-
-afterEach(() => {
- vi.useRealTimers();
- vi.unstubAllGlobals();
-});
-
-describe("enable / disable lifecycle", () => {
- it("arms a countdown when enabled and idle", () => {
- store.setEnabled("tab-1", true);
- const s = store.stateFor("tab-1");
- expect(s.enabled).toBe(true);
- expect(s.nextFireAt).not.toBeNull();
- });
-
- it("cancels the countdown when disabled", () => {
- store.setEnabled("tab-1", true);
- store.setEnabled("tab-1", false);
- expect(store.stateFor("tab-1").nextFireAt).toBeNull();
- });
-
- it("does not arm a disabled tab", () => {
- const s = store.stateFor("tab-2");
- expect(s.enabled).toBe(false);
- expect(s.nextFireAt).toBeNull();
- });
-});
-
-describe("firing cadence", () => {
- it("fires after the interval and captures the warming last-%", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 1000, cacheReadTokens: 900 });
- vi.stubGlobal("fetch", fetchMock);
-
- store.setRequestResolver(() => ({
- keyId: "k",
- modelId: "m",
- agentModels: null,
- reasoningEffort: "high",
- }));
- store.setEnabled("tab-1", true);
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
-
- expect(fetchMock).toHaveBeenCalledTimes(1);
- const [url, opts] = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock
- .calls[0] as [string, { body: string }];
- expect(url).toContain("/chat/warm");
- // The request forwards the SAME effort the real turn uses — it's an
- // Anthropic message-cache key, so warming must match it to refresh the
- // bucket the next real message reads.
- expect(JSON.parse(opts.body)).toMatchObject({
- tabId: "tab-1",
- keyId: "k",
- modelId: "m",
- reasoningEffort: "high",
- });
-
- const s = store.stateFor("tab-1");
- expect(s.lastPct).toBe(90); // 900 / 1000
- expect(s.error).toBeNull();
- });
-
- it("re-arms repeatedly (not a one-shot)", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 1000, cacheReadTokens: 800 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
- expect(fetchMock).toHaveBeenCalledTimes(1);
- // After the first fire it re-arms, so a second interval fires again.
- expect(store.stateFor("tab-1").nextFireAt).not.toBeNull();
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
- expect(fetchMock).toHaveBeenCalledTimes(2);
- });
-
- it("shows '-%' (null) before it has ever fired", () => {
- store.setEnabled("tab-1", true);
- expect(store.stateFor("tab-1").lastPct).toBeNull();
- });
-});
-
-describe("turn-state gating", () => {
- it("does NOT fire while a turn is active", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 10, cacheReadTokens: 5 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
- store.onTurnActive("tab-1");
- expect(store.stateFor("tab-1").nextFireAt).toBeNull();
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS * 2);
- await flush();
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it("re-arms when the turn ends (idle)", () => {
- store.setEnabled("tab-1", true);
- store.onTurnActive("tab-1");
- store.onTurnEnded("tab-1");
- expect(store.stateFor("tab-1").nextFireAt).not.toBeNull();
- });
-
- it("a real user message disables+resets the pending timer", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 10, cacheReadTokens: 5 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
- store.onUserMessage("tab-1");
- expect(store.stateFor("tab-1").nextFireAt).toBeNull();
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
- // The pending fire was cancelled by onUserMessage; nothing fired.
- expect(fetchMock).not.toHaveBeenCalled();
- });
-});
-
-describe("error surfacing", () => {
- it("captures a non-OK HTTP error into state.error", async () => {
- const fetchMock = vi.fn(() =>
- Promise.resolve({
- ok: false,
- status: 500,
- json: () => Promise.resolve({ error: "provider exploded" }),
- }),
- ) as unknown as typeof fetch;
- vi.stubGlobal("fetch", fetchMock);
-
- store.setEnabled("tab-1", true);
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
-
- expect(store.stateFor("tab-1").error).toBe("provider exploded");
- });
-
- it("captures a network rejection into state.error", async () => {
- const fetchMock = vi.fn(() =>
- Promise.reject(new Error("network down")),
- ) as unknown as typeof fetch;
- vi.stubGlobal("fetch", fetchMock);
-
- store.setEnabled("tab-1", true);
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
-
- expect(store.stateFor("tab-1").error).toBe("network down");
- });
-});
-
-describe("forgetTab / removeTab", () => {
- it("forgetTab clears state, timers, and persisted preference for a closed tab", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 10, cacheReadTokens: 5 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
- store.forgetTab("tab-1");
-
- // A fresh stateFor after removal is a default-off entry.
- expect(store.stateFor("tab-1").enabled).toBe(false);
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS * 2);
- await flush();
- expect(fetchMock).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
deleted file mode 100644
index 8639bff..0000000
--- a/packages/frontend/tests/chat-store.test.ts
+++ /dev/null
@@ -1,2203 +0,0 @@
-/**
- * Integration tests for the tab store at `src/lib/tabs.svelte.ts`.
- *
- * These tests drive the **real** Svelte 5 `$state`-backed store via
- * `createTabStore()` and `handleEvent()`. The previous version of this
- * file used a POJO harness (plain arrays) that duplicated the store
- * logic from the production file, which meant any drift between the
- * harness and the real code went undetected.
- *
- * What this catches:
- * - Logic bugs inside `handleEvent`, `applyChunkEvent`, `routeSystemEvent`.
- * - Reactivity contract bugs where the real `$state` proxies behave
- * differently than POJO arrays (e.g., reassignment patterns, derived
- * state propagation).
- * - Any case where the helper-imported-from-core (`appendEventToChunks`,
- * `applySystemEvent`) disagrees with how the store wires it.
- *
- * What this DOES NOT catch (a known limitation):
- * - The specific `structuredClone(svelteProxy)` failure that hit
- * production. Browsers' `structuredClone` rejects certain
- * Proxy/internal-slot patterns Svelte 5 uses; Bun's `structuredClone`
- * (which is what vitest runs against here) is more permissive and
- * happily clones these proxies. Empirically: temporarily reverting
- * `applyChunkEvent` to `structuredClone(m.chunks)` keeps these tests
- * green even though the same change breaks the browser at runtime.
- * Catching that class of bug would require a browser-runtime test
- * (Playwright or vitest browser mode) — out of scope for this round,
- * but worth filing.
- *
- * Mocks: `wsClient`, `config`, and the global `fetch` are mocked so the
- * store doesn't try to open a real WebSocket / read localStorage / hit
- * an HTTP backend during module init.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// `config.ts` reads `localStorage` at module load. Bun's runtime defines
-// `localStorage` as a partial shim that lacks `getItem`, so the real
-// module throws before we can even import the store. Mocking the
-// module itself sidesteps that entirely — tests don't depend on the
-// real API base resolution logic.
-vi.mock("../src/lib/config.js", () => ({
- config: {
- apiBase: "http://test.local:3000",
- wsUrl: "ws://test.local:3000/ws",
- defaultApiBase: "http://test.local:3000",
- setApiBase: vi.fn(),
- },
-}));
-
-// Mock the WS module before importing the store so the module-load
-// side effects (clearCallbacks/onEvent registration, status effect)
-// see the stub. Tests interact with the store via the returned
-// `handleEvent` method directly — no WS wiring needed.
-vi.mock("../src/lib/ws.svelte.js", () => ({
- wsClient: {
- connectionStatus: "connected",
- clearCallbacks: vi.fn(),
- onEvent: vi.fn(),
- send: vi.fn(),
- connect: vi.fn(),
- disconnect: vi.fn(),
- },
-}));
-
-// Mock fetch so async network operations (createNewTab POSTs, auto-skill
-// loaders, etc.) resolve immediately without hitting a real backend.
-beforeEach(() => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.reject(new Error("test: fetch mocked"))),
- );
-});
-
-import { computeContextUsage } from "../src/lib/context-window.js";
-import { appSettings } from "../src/lib/settings.svelte.js";
-import { createTabStore } from "../src/lib/tabs.svelte.js";
-import type { Chunk, PermissionPrompt } from "../src/lib/types.js";
-import { wsClient } from "../src/lib/ws.svelte.js";
-
-/**
- * Create a fresh store and a tab to drive events into. Returns both the
- * store and the tab id; tests should use `tabId` for `handleEvent` calls.
- */
-async function setupStoreWithTab() {
- const store = createTabStore();
- const tab = await store.createNewTab();
- return { store, tabId: tab.id };
-}
-
-/**
- * Read the active assistant message's chunks. Most chunk tests are
- * structured "drive events → inspect chunks of the resulting assistant
- * message" — this helper avoids the boilerplate.
- */
-function getAssistantChunks(store: ReturnType<typeof createTabStore>): Chunk[] | undefined {
- const tab = store.tabs[0];
- const assistant = tab?.renderGroups.find((m) => m.role === "assistant");
- return assistant?.chunks;
-}
-
-/**
- * Build a raw `ChunkRow` as the `GET /tabs/:id/chunks` endpoint returns them.
- * The chunk-native store loads/paginates raw rows and groups them for render.
- */
-function chunkRow(
- id: string,
- tabId: string,
- seq: number,
- turnId: string,
- role: "user" | "assistant" | "tool" | "system",
- type: "text" | "thinking" | "tool_call" | "tool_result" | "error" | "system",
- data: unknown,
- step = 0,
-): Record<string, unknown> {
- return { id, tabId, seq, turnId, step, role, type, data, createdAt: seq };
-}
-
-describe("tabStore — streaming chunk flow (real $state)", () => {
- it("text-delta creates a streaming assistant message and appends deltas", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- store.handleEvent({ type: "text-delta", delta: "Hello", tabId });
-
- const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
- expect(assistant).toBeDefined();
- expect(assistant?.isStreaming).toBe(true);
- expect(assistant?.chunks).toEqual([{ type: "text", text: "Hello" }]);
-
- store.handleEvent({ type: "text-delta", delta: " world", tabId });
- expect(getAssistantChunks(store)).toEqual([{ type: "text", text: "Hello world" }]);
- });
-
- it("consecutive text-deltas coalesce into one text chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "A", tabId });
- store.handleEvent({ type: "text-delta", delta: "B", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]).toEqual({ type: "text", text: "AB" });
- });
-
- it("tool-call after text creates a tool-batch chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "Calling tool...", tabId });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "search", arguments: { query: "test" } },
- tabId,
- });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[0]?.type).toBe("text");
- expect(chunks?.[1]?.type).toBe("tool-batch");
- if (chunks?.[1]?.type === "tool-batch") {
- expect(chunks[1].calls).toHaveLength(1);
- expect(chunks[1].calls[0]?.id).toBe("tc1");
- expect(chunks[1].calls[0]?.name).toBe("search");
- }
- });
-
- it("tool-result fills in result on the matching tool-batch entry", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "...", tabId });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "search", arguments: { query: "x" } },
- tabId,
- });
- store.handleEvent({
- type: "tool-result",
- toolResult: { toolCallId: "tc1", result: "found it", isError: false },
- tabId,
- });
- const chunks = getAssistantChunks(store);
- const tb = chunks?.[1];
- if (tb?.type === "tool-batch") {
- expect(tb.calls[0]?.result).toBe("found it");
- expect(tb.calls[0]?.isError).toBe(false);
- } else {
- expect.fail("Expected tool-batch chunk");
- }
- });
-
- it("two consecutive tool-calls coalesce into one tool-batch with two entries", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read", arguments: {} },
- tabId,
- });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc2", name: "write", arguments: {} },
- tabId,
- });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]?.type).toBe("tool-batch");
- if (chunks?.[0]?.type === "tool-batch") {
- expect(chunks[0].calls).toHaveLength(2);
- }
- });
-
- it("text after tool-call opens a new text chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read", arguments: {} },
- tabId,
- });
- store.handleEvent({ type: "text-delta", delta: "Result: here", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[0]?.type).toBe("tool-batch");
- expect(chunks?.[1]).toEqual({ type: "text", text: "Result: here" });
- });
-
- it("done finalizes the current assistant message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "partial", tabId });
- store.handleEvent({
- type: "done",
- message: { role: "assistant", chunks: [] },
- tabId,
- } as Parameters<typeof store.handleEvent>[0]);
- const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
- expect(assistant?.chunks).toEqual([{ type: "text", text: "partial" }]);
- expect(assistant?.isStreaming).toBe(false);
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- });
-
- it("status:idle clears currentAssistantId", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "status", status: "running", tabId });
- expect(store.tabs[0]?.agentStatus).toBe("running");
- store.handleEvent({ type: "status", status: "idle", tabId });
- expect(store.tabs[0]?.agentStatus).toBe("idle");
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- });
-
- it("reasoning-delta accumulates a thinking chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "reasoning-delta", delta: "First thought.", tabId });
- expect(getAssistantChunks(store)).toEqual([{ type: "thinking", text: "First thought." }]);
-
- store.handleEvent({ type: "reasoning-delta", delta: " Second thought.", tabId });
- expect(getAssistantChunks(store)).toEqual([
- { type: "thinking", text: "First thought. Second thought." },
- ]);
- });
-
- it("reasoning-end seals the thinking chunk with metadata (end-to-end signature seal)", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- store.handleEvent({ type: "reasoning-delta", delta: "plan", tabId });
- store.handleEvent({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "wire-sig" } },
- tabId,
- });
-
- const chunks = getAssistantChunks(store);
- expect(chunks).toEqual([
- { type: "thinking", text: "plan", metadata: { anthropic: { signature: "wire-sig" } } },
- ]);
- });
-
- it("reasoning-delta after reasoning-end opens a new thinking chunk (v6 multi-block)", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- // First thinking block: delta → seal
- store.handleEvent({ type: "reasoning-delta", delta: "block-1", tabId });
- store.handleEvent({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "sig-1" } },
- tabId,
- });
-
- // Second thinking block: new delta after seal must NOT extend the sealed chunk
- store.handleEvent({ type: "reasoning-delta", delta: "block-2", tabId });
-
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- // First chunk sealed — text must not have been extended
- expect(chunks?.[0]).toEqual({
- type: "thinking",
- text: "block-1",
- metadata: { anthropic: { signature: "sig-1" } },
- });
- // Second chunk is fresh — no metadata yet
- expect(chunks?.[1]).toEqual({ type: "thinking", text: "block-2" });
- });
-
- it("reasoning-end without metadata is a no-op (subsequent delta still extends the chunk)", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- store.handleEvent({ type: "reasoning-delta", delta: "start", tabId });
- // reasoning-end with no metadata → helper returns early, chunk stays unsealed
- store.handleEvent({ type: "reasoning-end", tabId });
- // Next delta should extend the existing (still unsealed) chunk
- store.handleEvent({ type: "reasoning-delta", delta: " continued", tabId });
-
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]).toEqual({ type: "thinking", text: "start continued" });
- });
-
- it("interleaved think→text→think yields three chunks in order", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "reasoning-delta", delta: "thinking-1", tabId });
- store.handleEvent({ type: "text-delta", delta: "speaking-1", tabId });
- store.handleEvent({ type: "reasoning-delta", delta: "thinking-2", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(3);
- expect(chunks?.[0]?.type).toBe("thinking");
- expect(chunks?.[1]?.type).toBe("text");
- expect(chunks?.[2]?.type).toBe("thinking");
- });
-
- it("error event during a turn appends an error chunk to the in-flight message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "before", tabId });
- store.handleEvent({ type: "error", error: "something went wrong", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[0]).toEqual({ type: "text", text: "before" });
- expect(chunks?.[1]?.type).toBe("error");
- if (chunks?.[1]?.type === "error") {
- expect(chunks[1].message).toBe("something went wrong");
- }
- expect(store.tabs[0]?.agentStatus).toBe("error");
- });
-
- it("error event with no in-flight turn opens a fresh assistant message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "error", error: "boom", tabId });
- const assistantMessages =
- store.tabs[0]?.renderGroups.filter((m) => m.role === "assistant") ?? [];
- expect(assistantMessages.length).toBeGreaterThanOrEqual(1);
- const errChunks = assistantMessages[assistantMessages.length - 1]?.chunks;
- expect(errChunks).toHaveLength(1);
- expect(errChunks?.[0]?.type).toBe("error");
- if (errChunks?.[0]?.type === "error") {
- expect(errChunks[0].message).toBe("boom");
- }
- expect(store.tabs[0]?.agentStatus).toBe("error");
- });
-
- it("notice during a turn appends a system chunk on the assistant message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "hi", tabId });
- store.handleEvent({ type: "notice", message: "heads up", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[1]?.type).toBe("system");
- if (chunks?.[1]?.type === "system") {
- expect(chunks[1].kind).toBe("notice");
- expect(chunks[1].text).toBe("heads up");
- }
- });
-
- it("notice with no turn in flight creates a role:system message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "notice", message: "standalone", tabId });
- const systemMsg = store.tabs[0]?.renderGroups.find((m) => m.role === "system");
- expect(systemMsg).toBeDefined();
- const chunks = systemMsg?.chunks;
- expect(chunks).toHaveLength(1);
- if (chunks?.[0]?.type === "system") {
- expect(chunks[0].text).toBe("standalone");
- }
- });
-
- it("two notices with no turn coalesce onto the same system message as two chunks", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "notice", message: "first", tabId });
- store.handleEvent({ type: "notice", message: "second", tabId });
- const sysMsgs = store.tabs[0]?.renderGroups.filter((m) => m.role === "system") ?? [];
- expect(sysMsgs).toHaveLength(1);
- expect(sysMsgs[0]?.chunks).toHaveLength(2);
- });
-
- it("shell-output stdout/stderr append to the most recent tool-batch entry", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } },
- tabId,
- });
- store.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout", tabId });
- store.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout", tabId });
- store.handleEvent({ type: "shell-output", data: "err line\n", stream: "stderr", tabId });
- const chunk = getAssistantChunks(store)?.[0];
- if (chunk?.type === "tool-batch") {
- expect(chunk.calls[0]?.shellOutput?.stdout).toBe("file1\nfile2\n");
- expect(chunk.calls[0]?.shellOutput?.stderr).toBe("err line\n");
- } else {
- expect.fail("Expected tool-batch chunk");
- }
- });
-});
-
-describe("tabStore — reactivity contract", () => {
- // These tests specifically exercise the structuredClone-vs-$state.snapshot
- // regression. If applyChunkEvent reverts to `structuredClone(m.chunks)`,
- // the call will throw DataCloneError on the Svelte reactive proxy, the
- // throw will surface (post-fix to ws.svelte.ts) or be swallowed (pre-fix),
- // and either way the chunks below will end up empty rather than
- // populated. These assertions ensure the bug class can't return silently.
-
- it("a streaming sequence produces non-empty chunks on a real $state-backed message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "status", status: "running", tabId });
- store.handleEvent({ type: "reasoning-delta", delta: "I should...", tabId });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "t1", name: "list_files", arguments: { path: "." } },
- tabId,
- });
- store.handleEvent({
- type: "tool-result",
- toolResult: { toolCallId: "t1", result: "files", isError: false },
- tabId,
- });
- store.handleEvent({ type: "text-delta", delta: "Result is...", tabId });
- store.handleEvent({ type: "status", status: "idle", tabId });
-
- const chunks = getAssistantChunks(store);
- // 3 chunks: thinking, tool-batch, text. Anything less means the
- // reactive-clone step swallowed events.
- expect(chunks).toHaveLength(3);
- expect(chunks?.[0]?.type).toBe("thinking");
- expect(chunks?.[1]?.type).toBe("tool-batch");
- expect(chunks?.[2]?.type).toBe("text");
- // And the chunks must NOT be reactive proxies — they must be
- // plain snapshot-cloned objects safe to serialize/pass around.
- // Probing via JSON round-trip is a cheap correctness check.
- expect(() => JSON.stringify(chunks)).not.toThrow();
- });
-
- it("multiple events on the same message accumulate (this is the chunks=0 regression)", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Drive a fairly large number of deltas. The original bug had every
- // content event after the first one silently failing — so this would
- // produce 1 chunk with 1 char (or 0 chunks if the first one also
- // failed for some reason). With the fix, the text accumulates.
- for (let i = 0; i < 50; i++) {
- store.handleEvent({ type: "text-delta", delta: `${i} `, tabId });
- }
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]?.type).toBe("text");
- if (chunks?.[0]?.type === "text") {
- // "0 1 2 ... 49 " is well over 100 chars.
- expect(chunks[0].text.length).toBeGreaterThan(100);
- }
- });
-
- it("statuses event resyncs a tab the frontend thought was running but the backend says is idle", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Drive the frontend into 'thinks it's running' state.
- store.handleEvent({ type: "status", status: "running", tabId });
- store.handleEvent({ type: "text-delta", delta: "starting", tabId });
- expect(store.tabs[0]?.agentStatus).toBe("running");
- expect(store.tabs[0]?.currentAssistantId).not.toBeNull();
-
- // Backend says: idle. (Simulates the WS-reconnect statuses snapshot
- // after the in-flight agent was lost — bun --watch restart, network
- // hiccup, etc.)
- store.handleEvent({
- type: "statuses",
- statuses: { [tabId]: { status: "idle" } },
- });
-
- expect(store.tabs[0]?.agentStatus).toBe("idle");
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- // The streaming flag on the in-flight message should be cleared.
- const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
- expect(assistant?.isStreaming).toBe(false);
- });
-});
-
-describe("tabStore — permission flow", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("permission-prompt sets pendingPermissions", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: ["*"],
- always: ["*"],
- description: "Run a command",
- metadata: { command: "ls" },
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- expect(store.pendingPermissions).toHaveLength(1);
- expect(store.pendingPermissions[0]?.id).toBe("p1");
- });
-
- it("permission-prompt replaces previous pending permissions", async () => {
- const store = createTabStore();
- const p1: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: [],
- always: [],
- description: "First",
- metadata: {},
- };
- const p2: PermissionPrompt = {
- id: "p2",
- permission: "read",
- patterns: [],
- always: [],
- description: "Second",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [p1] });
- store.handleEvent({ type: "permission-prompt", pending: [p2] });
- expect(store.pendingPermissions).toHaveLength(1);
- expect(store.pendingPermissions[0]?.id).toBe("p2");
- });
-
- it("replyPermission removes the permission and sends over WS", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: [],
- always: [],
- description: "Run command",
- metadata: { command: "echo hi" },
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p1", "once");
- expect(store.pendingPermissions).toHaveLength(0);
- expect(wsClient.send).toHaveBeenCalledWith({
- type: "permission-reply",
- id: "p1",
- reply: "once",
- });
- });
-
- it("replyPermission with 'always' sends correct payload", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p2",
- permission: "read",
- patterns: ["src/**"],
- always: ["src/**"],
- description: "Read a file",
- metadata: { filepath: "src/foo.ts" },
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p2", "always");
- expect(wsClient.send).toHaveBeenCalledWith({
- type: "permission-reply",
- id: "p2",
- reply: "always",
- });
- expect(store.pendingPermissions).toHaveLength(0);
- });
-
- it("replyPermission with 'reject' removes the permission", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p3",
- permission: "edit",
- patterns: [],
- always: [],
- description: "Edit a file",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p3", "reject");
- expect(store.pendingPermissions).toHaveLength(0);
- expect(wsClient.send).toHaveBeenCalledWith({
- type: "permission-reply",
- id: "p3",
- reply: "reject",
- });
- });
-
- it("replyPermission adds an entry to permissionLog", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: ["*"],
- always: ["*"],
- description: "Run a command",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p1", "once");
- expect(store.permissionLog).toHaveLength(1);
- expect(store.permissionLog[0]?.permission).toBe("bash");
- expect(store.permissionLog[0]?.action).toBe("once");
- expect(store.permissionLog[0]?.description).toBe("Run a command");
- });
-
- it("permissionLog accumulates multiple entries", async () => {
- const store = createTabStore();
- const p1: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: [],
- always: [],
- description: "First",
- metadata: {},
- };
- const p2: PermissionPrompt = {
- id: "p2",
- permission: "read",
- patterns: [],
- always: [],
- description: "Second",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [p1, p2] });
- store.replyPermission("p1", "always");
- store.replyPermission("p2", "reject");
- expect(store.permissionLog).toHaveLength(2);
- expect(store.permissionLog[0]?.action).toBe("always");
- expect(store.permissionLog[1]?.action).toBe("reject");
- });
-
- it("replyPermission for unknown id does not add to log", async () => {
- const store = createTabStore();
- store.replyPermission("nonexistent", "once");
- expect(store.permissionLog).toHaveLength(0);
- });
-});
-
-// Shell output JSON parsing — a small helper that mirrors logic in
-// ToolCallDisplay.svelte. Kept here as a self-contained unit; the
-// component itself isn't tested in this file.
-function parseShellResult(
- result: string,
-): { stdout: string; stderr: string; exitCode: number } | null {
- try {
- const parsed = JSON.parse(result) as unknown;
- if (
- parsed !== null &&
- typeof parsed === "object" &&
- "stdout" in parsed &&
- "stderr" in parsed &&
- "exitCode" in parsed
- ) {
- const p = parsed as Record<string, unknown>;
- return {
- stdout: String(p.stdout ?? ""),
- stderr: String(p.stderr ?? ""),
- exitCode: Number(p.exitCode ?? 0),
- };
- }
- return null;
- } catch {
- return null;
- }
-}
-
-describe("shell output parsing helper", () => {
- it("parses a valid shell result JSON", () => {
- const result = JSON.stringify({ stdout: "hello\n", stderr: "", exitCode: 0 });
- const parsed = parseShellResult(result);
- expect(parsed).not.toBeNull();
- expect(parsed?.stdout).toBe("hello\n");
- expect(parsed?.stderr).toBe("");
- expect(parsed?.exitCode).toBe(0);
- });
-
- it("parses non-zero exit code and stderr", () => {
- const result = JSON.stringify({ stdout: "", stderr: "error: not found", exitCode: 1 });
- const parsed = parseShellResult(result);
- expect(parsed?.exitCode).toBe(1);
- expect(parsed?.stderr).toBe("error: not found");
- });
-
- it("returns null for invalid JSON", () => {
- expect(parseShellResult("not json")).toBeNull();
- });
-
- it("returns null for JSON that lacks required fields", () => {
- expect(parseShellResult(JSON.stringify({ stdout: "foo" }))).toBeNull();
- });
-
- it("returns null for non-object JSON", () => {
- expect(parseShellResult(JSON.stringify(42))).toBeNull();
- });
-});
-
-// ─── hydrateFromBackend ─────────────────────────────────────────
-//
-// Verifies the browser-reopen restore path: GET /tabs + GET /status +
-// GET /tabs/:id/messages combined into the in-memory tab store with
-// in-flight chunks seeded for any running tab.
-
-describe("hydrateFromBackend", () => {
- it("restores tabs from /tabs with their persisted messages", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- { id: "t1", title: "First", keyId: null, modelId: null, parentTabId: null },
- { id: "t2", title: "Second", keyId: "k", modelId: "m", parentTabId: null },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ statuses: {} }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/t1/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- chunks: [
- chunkRow("m1", "t1", 0, "u1", "user", "text", { text: "hello" }),
- chunkRow("m2", "t1", 1, "u1", "assistant", "text", { text: "hi back" }),
- ],
- total: 2,
- oldestSeq: 0,
- }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/t2/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(2);
- expect(store.tabs.length).toBe(2);
- expect(store.tabs[0]?.id).toBe("t1");
- expect(store.tabs[0]?.renderGroups.length).toBe(2);
- expect(store.tabs[1]?.id).toBe("t2");
- expect(store.tabs[1]?.renderGroups.length).toBe(0);
- expect(store.activeTabId).toBe("t1");
- });
-
- it("seeds the in-flight assistant message from /status for a running tab", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- { id: "tr", title: "Running tab", keyId: null, modelId: null, parentTabId: null },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- statuses: {
- tr: {
- status: "running",
- currentAssistantId: "live-msg-id",
- currentChunks: [
- { type: "thinking", text: "still thinking" },
- { type: "text", text: "partial " },
- ],
- },
- },
- }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tr/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- chunks: [chunkRow("u1", "tr", 0, "turn-r", "user", "text", { text: "go" })],
- total: 1,
- oldestSeq: 0,
- }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(1);
- const tab = store.tabs[0];
- expect(tab?.agentStatus).toBe("running");
- expect(tab?.currentAssistantId).toBe("live-msg-id");
- // Two messages: the user message + the seeded in-flight assistant.
- expect(tab?.renderGroups.length).toBe(2);
- const inflight = tab?.renderGroups.find((m) => m.id === "live-msg-id");
- expect(inflight).toBeDefined();
- expect(inflight?.isStreaming).toBe(true);
- expect(inflight?.chunks).toEqual([
- { type: "thinking", text: "still thinking" },
- { type: "text", text: "partial " },
- ]);
- });
-
- it("returns 0 and leaves tabs empty when /tabs fails", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(0);
- expect(store.tabs.length).toBe(0);
- });
-
- it("returns 0 and leaves tabs empty when /tabs returns an empty array", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ tabs: [] }) });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(0);
- expect(store.tabs.length).toBe(0);
- });
-
- it("is a no-op when the store already has tabs (idempotency)", async () => {
- const store = createTabStore();
- // Pretend the store already has a tab (e.g. from a hot-reload).
- // We do this by reaching into the store via the public API.
- // Use the create path with mocked fetch failure (existing
- // `createNewTab` already tolerates fetch failure — adds locally).
- // (beforeEach already stubs fetch to reject, so createNewTab will
- // proceed past the failed POST and add the tab locally.)
- await store.createNewTab();
- expect(store.tabs.length).toBe(1);
-
- // Now swap to a fetch that would lie about there being 3 tabs;
- // hydrateFromBackend must NOT call it. We use a fresh mock that
- // rejects to catch any stray background async calls too.
- let hydrateCallCount = 0;
- const sentinelFetch = vi.fn((url: string) => {
- // Allow background auto-agent/skill fetches that fire from
- // createNewTab's void async closure (autoSelectDefaultAgent,
- // autoCheckDefaultSkills) — they use /agents and /skills paths,
- // not /tabs. Reject them so they don't interfere.
- if (url.includes("/agents") || url.includes("/skills")) {
- return Promise.reject(new Error("test: background fetch ignored"));
- }
- // Any /tabs call would mean hydrateFromBackend ran — count it.
- hydrateCallCount++;
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ tabs: [{ id: "x" }, { id: "y" }, { id: "z" }] }),
- });
- });
- vi.stubGlobal("fetch", sentinelFetch);
- const n = await store.hydrateFromBackend();
- expect(n).toBe(0);
- expect(store.tabs.length).toBe(1);
- expect(hydrateCallCount).toBe(0);
- });
-
- it("restores a tab with an idle status when /status omits it", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [{ id: "ti", title: "Idle", keyId: null, modelId: null, parentTabId: null }],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/ti/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(1);
- expect(store.tabs[0]?.agentStatus).toBe("idle");
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- });
-
- it("restores a tab with empty messages when /tabs/:id/messages fails (per-tab failure isolation)", async () => {
- // The hydrateFromBackend implementation wraps each per-tab
- // messages fetch in a try/catch so one tab's failure can't
- // destroy the whole restore pass. This test covers BOTH failure
- // modes the try/catch protects against:
- // - response.ok === false (HTTP error like 500)
- // - the fetch rejects (network error)
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tA",
- title: "Healthy",
- keyId: null,
- modelId: null,
- parentTabId: null,
- },
- {
- id: "tB",
- title: "Broken (HTTP 500)",
- keyId: null,
- modelId: null,
- parentTabId: null,
- },
- {
- id: "tC",
- title: "Broken (network)",
- keyId: null,
- modelId: null,
- parentTabId: null,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tA/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- chunks: [chunkRow("msg-a", "tA", 0, "turn-a", "user", "text", { text: "ok" })],
- total: 1,
- oldestSeq: 0,
- }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tB/chunks")) {
- // HTTP error path: response is not ok.
- return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tC/chunks")) {
- // Network error path: the fetch itself rejects.
- return Promise.reject(new Error("simulated network failure"));
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(3);
-
- // Healthy tab restored with its message.
- const tA = store.tabs.find((t) => t.id === "tA");
- expect(tA?.renderGroups.length).toBe(1);
- expect(tA?.renderGroups[0]?.chunks).toEqual([{ type: "text", text: "ok" }]);
-
- // Both broken tabs restored with empty message lists — neither
- // crashed the hydration nor leaked an error chunk into the UI.
- const tB = store.tabs.find((t) => t.id === "tB");
- expect(tB).toBeDefined();
- expect(tB?.renderGroups.length).toBe(0);
- expect(tB?.agentStatus).toBe("idle");
-
- const tC = store.tabs.find((t) => t.id === "tC");
- expect(tC).toBeDefined();
- expect(tC?.renderGroups.length).toBe(0);
- expect(tC?.agentStatus).toBe("idle");
- });
-
- // ─── usage persistence: seed cacheStats from the backend aggregate ──
- it("seeds cacheStats from a tab's usageStats on hydrate (reload persistence)", async () => {
- const usageStats = {
- inputTokens: 2200,
- outputTokens: 100,
- cacheReadTokens: 1000,
- cacheWriteTokens: 1000,
- requests: 2,
- last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tu",
- title: "Has usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- {
- id: "tn",
- title: "No usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats: null,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tu/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tn/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(2);
- // Tab with persisted usage → cacheStats seeded directly from the aggregate.
- expect(store.tabs.find((t) => t.id === "tu")?.cacheStats).toEqual(usageStats);
- // Tab with null usageStats → cacheStats stays undefined (no usage yet).
- expect(store.tabs.find((t) => t.id === "tn")?.cacheStats).toBeUndefined();
- });
-
- it("does not re-seed or double-count cacheStats on a statuses reconnect after hydrate", async () => {
- const usageStats = {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- requests: 1,
- last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tr",
- title: "Reconnect",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tr/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- await store.hydrateFromBackend();
- expect(store.tabs.find((t) => t.id === "tr")?.cacheStats).toEqual(usageStats);
-
- // A WS reconnect snapshot must NOT touch cacheStats (in-session live
- // `usage` events own the running totals; the aggregate seed is reload-only).
- store.handleEvent({ type: "statuses", statuses: { tr: { status: "idle" } } });
- expect(store.tabs.find((t) => t.id === "tr")?.cacheStats).toEqual(usageStats);
- });
-
- it("keeps accumulating live usage events after a hydrate seed", async () => {
- const usageStats = {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- requests: 1,
- last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tl",
- title: "Live after hydrate",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tl/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- await store.hydrateFromBackend();
-
- // A new in-session usage event folds ON TOP of the seeded aggregate.
- store.handleEvent({
- type: "usage",
- tabId: "tl",
- usage: { inputTokens: 200, outputTokens: 10, cacheReadTokens: 150, cacheWriteTokens: 0 },
- });
- const stats = store.tabs.find((t) => t.id === "tl")?.cacheStats;
- expect(stats?.requests).toBe(2);
- expect(stats?.inputTokens).toBe(1200);
- expect(stats?.outputTokens).toBe(50);
- expect(stats?.cacheReadTokens).toBe(150);
- expect(stats?.cacheWriteTokens).toBe(900);
- expect(stats?.last).toEqual({
- inputTokens: 200,
- outputTokens: 10,
- cacheReadTokens: 150,
- cacheWriteTokens: 0,
- });
- });
-
- // Cross-feature contract (Context Window view, branch u2): the panel derives
- // current context size from `cacheStats.last` via computeContextUsage. This
- // test proves persistence restores that field on hydrate, so the view shows a
- // real "x / max" immediately after a reload on a NEW DEVICE — not "No context
- // data yet". Guards the contract so neither side can silently break it.
- it("restores cacheStats.last on hydrate so the Context Window view has data after reload", async () => {
- const usageStats = {
- inputTokens: 90000,
- outputTokens: 3000,
- cacheReadTokens: 40000,
- cacheWriteTokens: 5000,
- requests: 3,
- // Most recent request's snapshot — the numerator the view reads.
- last: { inputTokens: 47000, outputTokens: 1200, cacheReadTokens: 30000, cacheWriteTokens: 0 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tc",
- title: "Context after reload",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tc/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- await store.hydrateFromBackend();
-
- // What App.svelte passes into ContextWindowPanel: the active tab's cacheStats
- // plus the model's max (re-resolved from models.dev on load — here 200k).
- const cacheStats = store.tabs.find((t) => t.id === "tc")?.cacheStats ?? null;
- expect(cacheStats?.last).not.toBeNull();
-
- const usage = computeContextUsage(cacheStats, 200000);
- // current = last.inputTokens + last.outputTokens (47000 + 1200), NOT the
- // cumulative session totals (which would double-count history).
- expect(usage.current).toBe(48200);
- expect(usage.max).toBe(200000);
- expect(usage.percent).toBeCloseTo(24.1, 5);
- });
-});
-
-// ─── statuses WS event with the wider TabStatusSnapshot shape ───
-//
-// The handler must reconcile snapshot.status against the local tab,
-// and (when running) seed currentChunks into the in-flight assistant
-// message.
-
-describe("handleEvent statuses with TabStatusSnapshot", () => {
- it("seeds the in-flight assistant message when a running snapshot arrives", async () => {
- const store = createTabStore();
- // Manually add a tab to the store via the existing createNewTab path
- // (fetch was mocked to reject in beforeEach; createNewTab tolerates).
- // We then drive a statuses event.
- await store.createNewTab();
- const tabId = store.tabs[0]?.id;
- if (!tabId) throw new Error("test fixture: tab id missing");
-
- store.handleEvent({
- type: "statuses",
- statuses: {
- [tabId]: {
- status: "running",
- currentAssistantId: "live-x",
- currentChunks: [{ type: "text", text: "live data" }],
- },
- },
- });
-
- const tab = store.tabs.find((t) => t.id === tabId);
- expect(tab?.agentStatus).toBe("running");
- expect(tab?.currentAssistantId).toBe("live-x");
- const inflight = tab?.renderGroups.find((m) => m.id === "live-x");
- expect(inflight).toBeDefined();
- expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]);
- expect(inflight?.isStreaming).toBe(true);
- });
-
- it("clears in-flight pointers when snapshot says the tab is idle", async () => {
- const store = createTabStore();
- await store.createNewTab();
- const tabId = store.tabs[0]?.id;
- if (!tabId) throw new Error("test fixture: tab id missing");
-
- // First put the tab into a running state with an in-flight message.
- store.handleEvent({
- type: "statuses",
- statuses: {
- [tabId]: {
- status: "running",
- currentAssistantId: "msg-a",
- currentChunks: [{ type: "text", text: "x" }],
- },
- },
- });
- expect(store.tabs.find((t) => t.id === tabId)?.currentAssistantId).toBe("msg-a");
-
- // Now snapshot says idle.
- store.handleEvent({
- type: "statuses",
- statuses: { [tabId]: { status: "idle" } },
- });
- const tab = store.tabs.find((t) => t.id === tabId);
- expect(tab?.agentStatus).toBe("idle");
- expect(tab?.currentAssistantId).toBeNull();
- const msgA = tab?.renderGroups.find((m) => m.id === "msg-a");
- expect(msgA?.isStreaming).toBe(false);
- });
-});
-
-describe("tabStore — cache rate (usage events)", () => {
- it("accumulates usage events into per-tab cacheStats and tracks the last request", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- // No usage yet.
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeUndefined();
-
- // First request: mostly a cache write (cold prefix).
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- });
- // Second request: mostly a cache hit.
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- });
-
- const stats = store.tabs.find((t) => t.id === tabId)?.cacheStats;
- expect(stats).toBeDefined();
- expect(stats?.requests).toBe(2);
- // Cumulative totals.
- expect(stats?.inputTokens).toBe(2200);
- expect(stats?.outputTokens).toBe(100);
- expect(stats?.cacheReadTokens).toBe(1000);
- expect(stats?.cacheWriteTokens).toBe(1000);
- // `last` reflects only the most recent request.
- expect(stats?.last).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("ignores usage events with no tabId", async () => {
- const { store } = await setupStoreWithTab();
- store.handleEvent({
- type: "usage",
- usage: { inputTokens: 10, outputTokens: 1, cacheReadTokens: 5, cacheWriteTokens: 0 },
- });
- expect(store.tabs[0]?.cacheStats).toBeUndefined();
- });
-
- it("turn-sealed REPLACES cacheStats with the carried DB aggregate (reconcile to truth)", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Live events accumulate during the turn.
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats?.inputTokens).toBe(1000);
-
- // turn-sealed carries the authoritative aggregate → cacheStats is REPLACED.
- const aggregate = {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- requests: 1,
- last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- };
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: aggregate });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(aggregate);
- });
-
- it("turn-sealed self-heals a live overshoot from a discarded fallback attempt", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Attempt 1 streamed usage live (overshoot), then rate-limited & discarded
- // server-side; attempt 2's usage also streamed live. Live = sum of BOTH.
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 999, outputTokens: 9, cacheReadTokens: 0, cacheWriteTokens: 0 },
- });
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- });
- const overshoot = store.tabs.find((t) => t.id === tabId)?.cacheStats;
- expect(overshoot?.requests).toBe(2);
- expect(overshoot?.inputTokens).toBe(1221); // inflated: includes discarded attempt
-
- // The DB only persisted attempt 2 (the survivor). turn-sealed reconciles.
- const persisted = {
- inputTokens: 222,
- outputTokens: 22,
- cacheReadTokens: 100,
- cacheWriteTokens: 5,
- requests: 1,
- last: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- };
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: persisted });
- // Overshoot healed: cacheStats now matches the DB truth exactly.
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(persisted);
- });
-
- it("turn-sealed without usageStats leaves cacheStats untouched (back-compat)", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 500, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 },
- });
- const before = store.tabs.find((t) => t.id === tabId)?.cacheStats;
- // Older backend: turn-sealed carries no usageStats field.
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(before);
- });
-
- it("turn-sealed with usageStats: null clears cacheStats", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 500, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 },
- });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeDefined();
- // A null aggregate (no persisted usage rows) explicitly clears live stats.
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: null });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeUndefined();
- });
-});
-
-// ─── chunk-native store: eviction, pagination, reconcile ────────
-//
-// The store's source of truth for HISTORY is a flat ChunkRow[] (`tab.chunks`,
-// real seqs); the live turn is a transient tail (`tab.live`) folded from
-// stream deltas and reconciled into `chunks` on `turn-sealed`. `tab.renderGroups`
-// is a derived render projection. These tests drive the real store.
-
-describe("tabStore — chunk-native eviction / pagination / reconcile", () => {
- function chunksResponse(rows: Array<Record<string, unknown>>, total?: number) {
- const oldestSeq = rows.length > 0 ? ((rows[0]?.seq as number) ?? null) : null;
- return {
- ok: true,
- json: () => Promise.resolve({ chunks: rows, total: total ?? rows.length, oldestSeq }),
- };
- }
- function tabsListResponse(id: string) {
- return {
- ok: true,
- json: () => Promise.resolve({ tabs: [{ id, title: id, parentTabId: null }] }),
- };
- }
- function emptyStatuses() {
- return { ok: true, json: () => Promise.resolve({ statuses: {} }) };
- }
- const tick = () => new Promise((r) => setTimeout(r, 0));
-
- it("evicts sealed chunks to chunkLimit, trimming WITHIN one large turn", async () => {
- appSettings.chunkLimit = 5;
- try {
- // One turn of 10 chunk rows (the pathological single big turn).
- const rows = Array.from({ length: 10 }, (_, i) =>
- chunkRow(`c${i}`, "big", i, "turn-1", i === 0 ? "user" : "assistant", "text", {
- text: `t${i}`,
- }),
- );
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) return Promise.resolve(tabsListResponse("big"));
- if (url.endsWith("/status")) return Promise.resolve(emptyStatuses());
- if (url.split("?")[0]?.endsWith("/tabs/big/chunks"))
- return Promise.resolve(chunksResponse(rows, 10));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- await store.hydrateFromBackend();
- const tab = store.tabs.find((t) => t.id === "big");
- // Bounded to chunkLimit; the OLDEST rows of the single turn were trimmed.
- expect(tab?.chunks.length).toBe(5);
- expect(tab?.chunks.map((c) => c.seq)).toEqual([5, 6, 7, 8, 9]);
- // Pagination cursor points at a real remaining seq.
- expect(tab?.oldestLoadedSeq).toBe(5);
- } finally {
- appSettings.chunkLimit = 100;
- }
- });
-
- it("loadOlderChunks prepends an older page and dedupes by seq", async () => {
- appSettings.chunkLimit = 1000; // don't evict during this test
- try {
- const newer = [6, 7, 8, 9].map((s) =>
- chunkRow(`c${s}`, "pg", s, "t", "assistant", "text", { text: `t${s}` }),
- );
- // Overlaps the newer window at seq 6 — must dedupe.
- const older = [2, 3, 4, 5, 6].map((s) =>
- chunkRow(`c${s}`, "pg", s, "t", "assistant", "text", { text: `t${s}` }),
- );
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) return Promise.resolve(tabsListResponse("pg"));
- if (url.endsWith("/status")) return Promise.resolve(emptyStatuses());
- if (url.split("?")[0]?.endsWith("/tabs/pg/chunks")) {
- return Promise.resolve(
- url.includes("before=") ? chunksResponse(older, 8) : chunksResponse(newer, 8),
- );
- }
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- await store.hydrateFromBackend();
- let tab = store.tabs.find((t) => t.id === "pg");
- expect(tab?.chunks.map((c) => c.seq)).toEqual([6, 7, 8, 9]);
- expect(tab?.oldestLoadedSeq).toBe(6);
-
- await store.loadOlderChunks("pg");
- tab = store.tabs.find((t) => t.id === "pg");
- expect(tab?.chunks.map((c) => c.seq)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]);
- expect(tab?.oldestLoadedSeq).toBe(2);
- } finally {
- appSettings.chunkLimit = 100;
- }
- });
-
- it("turn-sealed folds the live turn into the sealed chunk log with real seqs", async () => {
- const sealed = [
- chunkRow("u", "rc", 0, "turn-x", "user", "text", { text: "hi" }),
- chunkRow("a", "rc", 1, "turn-x", "assistant", "text", { text: "answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/rc/chunks"))
- return Promise.resolve(chunksResponse(sealed, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "rc",
- title: "RC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-x", tabId: "rc" });
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "rc" });
- // While streaming: live tail holds the in-flight assistant; sealed empty.
- let tab = store.tabs.find((t) => t.id === "rc");
- expect(tab?.live.length).toBe(1);
- expect(tab?.chunks.length).toBe(0);
- // The live assistant is tagged with the turn id (stable render key basis).
- expect(tab?.live[0]?.turnId).toBe("turn-x");
-
- store.handleEvent({ type: "turn-sealed", turnId: "turn-x", tabId: "rc" });
- await tick(); // reconcile refetch is async
- tab = store.tabs.find((t) => t.id === "rc");
- expect(tab?.live.length).toBe(0);
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- expect(tab?.renderGroups.map((m) => m.role)).toEqual(["user", "assistant"]);
- // The sealed messages carry the SAME turn id as the live ones did, so the
- // turn-scoped render key is stable across reconcile (no remount/flash).
- expect(tab?.renderGroups.every((m) => m.turnId === "turn-x")).toBe(true);
- });
-
- it("defers reconcile while scrolled up, then runs it on return to bottom", async () => {
- const sealed = [chunkRow("u", "df", 0, "turn-y", "user", "text", { text: "q" })];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/df/chunks"))
- return Promise.resolve(chunksResponse(sealed, 1));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "df",
- title: "DF",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-y", tabId: "df" });
- store.handleEvent({ type: "text-delta", delta: "partial", tabId: "df" });
- store.setScrolledUp("df", true);
- store.handleEvent({ type: "turn-sealed", turnId: "turn-y", tabId: "df" });
- await tick();
- // Deferred: live tail still present, not yet folded into sealed chunks.
- expect(store.tabs.find((t) => t.id === "df")?.live.length).toBe(1);
-
- store.setScrolledUp("df", false);
- await tick();
- const tab = store.tabs.find((t) => t.id === "df");
- expect(tab?.live.length).toBe(0);
- expect(tab?.chunks.length).toBe(1);
- });
-
- it("preserves an optimistic queued user message when an earlier turn reconciles", async () => {
- const sealed = [
- chunkRow("u", "q", 0, "turn-a", "user", "text", { text: "first" }),
- chunkRow("a", "q", 1, "turn-a", "assistant", "text", { text: "answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/q/chunks"))
- return Promise.resolve(chunksResponse(sealed, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "q",
- title: "Q",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "q" });
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "q" });
- // User queues a follow-up WHILE turn-a streams (optimistic, no turn id yet).
- store.handleEvent({
- type: "message-queued",
- tabId: "q",
- messageId: "q1",
- message: "do this next",
- });
- expect(store.tabs.find((t) => t.id === "q")?.live.some((m) => m.id === "queued-q1")).toBe(true);
-
- // turn-a seals → reconcile. The queued user bubble must survive.
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "q" });
- await tick();
- const tab = store.tabs.find((t) => t.id === "q");
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(true);
- // The sealed turn's assistant was folded out of the live tail.
- expect(tab?.live.some((m) => m.role === "assistant")).toBe(false);
- });
-
- it("turn-start backfill skips a pending queued row (race), tags only the initiator", async () => {
- // Realistic race: the user sends a prompt (plain optimistic row, no turn id
- // yet) and, before the WS `turn-start` arrives, sends a follow-up that the
- // backend queues (`queued-` prefix). When `turn-start` finally lands, the
- // backfill must tag ONLY the plain initiator and SKIP the pending queued
- // row — otherwise the queued row inherits the sealing turn's id and is
- // wiped on reconcile (the Pass-2 Blocker).
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "rq",
- title: "RQ",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.switchTab("rq"); // sendMessage targets the active tab
- const sealed = [
- chunkRow("u", "rq", 0, "turn-a", "user", "text", { text: "first" }),
- chunkRow("a", "rq", 1, "turn-a", "assistant", "text", { text: "answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string, opts?: { body?: string }) => {
- const path = url.split("?")[0] ?? "";
- if (path.endsWith("/tabs/rq/chunks")) return Promise.resolve(chunksResponse(sealed, 2));
- if (path.endsWith("/chat")) {
- // The 2nd send carries a queueId → backend reports it queued.
- const queued = (opts?.body ?? "").includes("queueId");
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve(queued ? { status: "queued", messageId: "srv" } : { status: "ok" }),
- });
- }
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({}),
- text: () => Promise.resolve(""),
- });
- }),
- );
-
- // A freshly created tab defaults to "running"; the agent is idle here.
- store.handleEvent({ type: "status", status: "idle", tabId: "rq" });
- await store.sendMessage("first"); // idle → plain optimistic user row
- store.handleEvent({ type: "status", status: "running", tabId: "rq" });
- await store.sendMessage("second"); // running → queued (queued- prefix)
-
- let tab = store.tabs.find((t) => t.id === "rq");
- const initiatorId = tab?.live.find((m) => m.role === "user" && !m.id.startsWith("queued-"))?.id;
- const queuedId = tab?.live.find((m) => m.id.startsWith("queued-"))?.id;
- expect(initiatorId).toBeTruthy();
- expect(queuedId).toBeTruthy();
-
- // turn-start arrives LATE: the queued follow-up is already in the live tail.
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "rq" });
- tab = store.tabs.find((t) => t.id === "rq");
- expect(tab?.live.find((m) => m.id === initiatorId)?.turnId).toBe("turn-a");
- expect(tab?.live.find((m) => m.id === queuedId)?.turnId).toBeUndefined();
-
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "rq" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "rq" });
- await tick();
- tab = store.tabs.find((t) => t.id === "rq");
- // Initiator folded cleanly into its sealed row (no duplicate user bubble);
- // the queued follow-up survives, still untagged and still queued.
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- expect(tab?.live.find((m) => m.id === queuedId)?.turnId).toBeUndefined();
- expect(tab?.live.some((m) => m.role === "assistant")).toBe(false);
- expect(tab?.renderGroups.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
- });
-
- it("a consumed interrupt message collapses into the sealed turn (no lingering bubble)", async () => {
- // During a running turn the user queues a message; the agent CONSUMES it
- // (interrupt), folding its text into the turn's persisted chunks. The
- // frontend's consumed user bubble must be BOUND to the in-flight turn so it
- // is dropped on reconcile — otherwise it lingers untagged forever AND
- // duplicates the interrupt text now living in the sealed chunk (Pass-3 Block).
- const sealed = [
- chunkRow("u", "ic", 0, "turn-a", "user", "text", { text: "do a thing" }),
- chunkRow("a", "ic", 1, "turn-a", "assistant", "text", { text: "working ...resumed" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/ic/chunks"))
- return Promise.resolve(chunksResponse(sealed, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "ic",
- title: "IC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "ic" });
- store.handleEvent({ type: "text-delta", delta: "working", tabId: "ic" });
- store.handleEvent({ type: "message-queued", tabId: "ic", messageId: "qx", message: "stop" });
- // Agent consumes the queued message mid-turn (interrupt injection).
- store.handleEvent({ type: "message-consumed", tabId: "ic", messageIds: ["qx"] });
- let tab = store.tabs.find((t) => t.id === "ic");
- const consumed = tab?.live.find((m) => m.id === "qx");
- expect(consumed).toBeTruthy();
- // The fix: the consumed row is bound to the active turn, not left untagged.
- expect(consumed?.turnId).toBe("turn-a");
- expect(tab?.queuedMessages.some((m) => m.id === "qx")).toBe(false);
-
- store.handleEvent({ type: "text-delta", delta: "resumed", tabId: "ic" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "ic" });
- await tick();
- tab = store.tabs.find((t) => t.id === "ic");
- // Collapsed to the persisted shape: the consumed bubble was dropped; only
- // the sealed chunks remain (no lingering / duplicated interrupt bubble).
- expect(tab?.live.length).toBe(0);
- expect(tab?.live.some((m) => m.id === "qx")).toBe(false);
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- });
-
- it("a continuation-consumed queued message becomes the next turn's initiator", async () => {
- // The turn-end fix: a message queued during turn-a is drained AFTER the
- // turn ends (reason: "continuation") to START turn-b. Its optimistic
- // `queued-` bubble must collapse into a single UNTAGGED user row so the
- // imminent turn-b `turn-start` tags it as that turn's initiator — and it
- // then folds cleanly into turn-b's sealed chunks (no linger, no dup).
- const sealedB = [
- chunkRow("ua", "cc", 0, "turn-a", "user", "text", { text: "first" }),
- chunkRow("aa", "cc", 1, "turn-a", "assistant", "text", { text: "first answer" }),
- chunkRow("ub", "cc", 2, "turn-b", "user", "text", { text: "next please" }),
- chunkRow("ab", "cc", 3, "turn-b", "assistant", "text", { text: "second answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/cc/chunks"))
- return Promise.resolve(chunksResponse(sealedB, 4));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "cc",
- title: "CC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- // Turn A streams; user queues a follow-up while it runs.
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "cc" });
- store.handleEvent({ type: "text-delta", delta: "first answer", tabId: "cc" });
- store.handleEvent({
- type: "message-queued",
- tabId: "cc",
- messageId: "q1",
- message: "next please",
- });
- let tab = store.tabs.find((t) => t.id === "cc");
- expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(true);
-
- // Turn A ends. The backend drains the queue as a CONTINUATION (not an
- // interrupt) and emits message-consumed{reason:"continuation"}.
- store.handleEvent({
- type: "message-consumed",
- tabId: "cc",
- messageIds: ["q1"],
- reason: "continuation",
- });
- tab = store.tabs.find((t) => t.id === "cc");
- // The queued- bubble collapsed into ONE plain (untagged, un-prefixed) user row.
- expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(false);
- const initiator = tab?.live.find((m) => m.role === "user");
- expect(initiator).toBeTruthy();
- expect(initiator?.id.startsWith("queued-")).toBe(false);
- expect(initiator?.turnId).toBeUndefined();
- expect(tab?.queuedMessages.some((m) => m.id === "q1")).toBe(false);
-
- // turn-a seals first (it was the running turn when the queue drained).
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "cc" });
- // Now turn-b starts — it must tag the collapsed initiator row.
- store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "cc" });
- tab = store.tabs.find((t) => t.id === "cc");
- const taggedInitiator = tab?.live.find((m) => m.role === "user" && m.turnId === "turn-b");
- expect(taggedInitiator).toBeTruthy();
-
- store.handleEvent({ type: "text-delta", delta: "second answer", tabId: "cc" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-b", tabId: "cc" });
- await tick();
- tab = store.tabs.find((t) => t.id === "cc");
- // Both turns are durable; the live tail is empty (initiator folded into
- // turn-b, no lingering/duplicated user bubble).
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1, 2, 3]);
- expect(tab?.live.length).toBe(0);
- expect(tab?.renderGroups.map((m) => m.role)).toEqual([
- "user",
- "assistant",
- "user",
- "assistant",
- ]);
- });
-
- it("collapses MULTIPLE continuation-consumed queued messages into one initiator row", async () => {
- // The backend joins several drained queued messages into a SINGLE user
- // turn (with a "\n---\n" separator). The frontend must mirror that: N
- // optimistic `queued-` bubbles collapse into exactly ONE untagged user
- // row carrying the joined text, which the next turn-start then tags.
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "mc",
- title: "MC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "mc" });
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "mc" });
- // Two follow-ups queued while turn-a streams.
- store.handleEvent({ type: "message-queued", tabId: "mc", messageId: "q1", message: "first" });
- store.handleEvent({ type: "message-queued", tabId: "mc", messageId: "q2", message: "second" });
- let tab = store.tabs.find((t) => t.id === "mc");
- expect(tab?.live.filter((m) => m.id.startsWith("queued-")).length).toBe(2);
-
- // Turn ends; backend drains BOTH as one continuation.
- store.handleEvent({
- type: "message-consumed",
- tabId: "mc",
- messageIds: ["q1", "q2"],
- reason: "continuation",
- });
- tab = store.tabs.find((t) => t.id === "mc");
- // Exactly one user row, untagged, with the joined text — no queued- bubbles left.
- const userRows = tab?.live.filter((m) => m.role === "user") ?? [];
- expect(userRows.length).toBe(1);
- expect(userRows[0]?.id.startsWith("queued-")).toBe(false);
- expect(userRows[0]?.turnId).toBeUndefined();
- const textChunk = userRows[0]?.chunks.find((c) => c.type === "text");
- expect(textChunk && textChunk.type === "text" ? textChunk.text : "").toBe("first\n---\nsecond");
- expect(tab?.queuedMessages.length).toBe(0);
-
- // The next turn-start tags that single collapsed row as its initiator.
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "mc" });
- store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "mc" });
- tab = store.tabs.find((t) => t.id === "mc");
- const tagged = tab?.live.filter((m) => m.role === "user" && m.turnId === "turn-b") ?? [];
- expect(tagged.length).toBe(1);
- });
-
- it("preserves a concurrent newer turn when an earlier deferred reconcile flushes", async () => {
- const sealedA = [
- chunkRow("ua", "c", 0, "turn-a", "user", "text", { text: "A?" }),
- chunkRow("aa", "c", 1, "turn-a", "assistant", "text", { text: "A!" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/c/chunks"))
- return Promise.resolve(chunksResponse(sealedA, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "c",
- title: "C",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- // Turn A streams; user scrolls up; A finishes → reconcile deferred.
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "c" });
- store.handleEvent({ type: "text-delta", delta: "A!", tabId: "c" });
- store.setScrolledUp("c", true);
- store.handleEvent({ type: "status", status: "idle", tabId: "c" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "c" });
- // Turn B (a queued message) starts streaming while still scrolled up.
- store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "c" });
- store.handleEvent({ type: "status", status: "running", tabId: "c" });
- store.handleEvent({ type: "text-delta", delta: "B in progress", tabId: "c" });
- let tab = store.tabs.find((t) => t.id === "c");
- expect(tab?.liveTurnId).toBe("turn-b");
- const bId = tab?.currentAssistantId;
- expect(bId).toBeTruthy();
-
- // User scrolls down → the deferred reconcile for turn-a flushes.
- store.setScrolledUp("c", false);
- await tick();
- tab = store.tabs.find((t) => t.id === "c");
- // Turn A folded into the sealed log...
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- // ...but turn B's in-flight state survived intact (no wipe / no remount).
- expect(tab?.liveTurnId).toBe("turn-b");
- expect(tab?.currentAssistantId).toBe(bId);
- expect(tab?.live.some((m) => m.turnId === "turn-b")).toBe(true);
- expect(tab?.live.some((m) => m.turnId === "turn-a")).toBe(false);
- });
-});
-
-describe("tabStore — tab reorder", () => {
- it("reorders user tabs and persists the new order", async () => {
- const calls: Array<{ url: string; body: string }> = [];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string, opts?: { body?: string }) => {
- calls.push({ url, body: opts?.body ?? "" });
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
- }),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
- const c = await store.createNewTab();
- expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id, c.id]);
-
- // Move the last tab to the front.
- store.reorderTabs([c.id, a.id, b.id]);
- expect(store.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
-
- const reorderCall = calls.find((call) => call.url.endsWith("/tabs/reorder"));
- expect(reorderCall).toBeTruthy();
- expect(JSON.parse(reorderCall?.body ?? "{}")).toEqual({ ids: [c.id, a.id, b.id] });
- });
-
- it("ignores a stale order that doesn't cover all user tabs", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
- store.reorderTabs([a.id]); // missing b → no-op
- expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id]);
- });
-
- it("keeps subagent tabs after the user tabs when reordering", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
- // A subagent tab arrives via WS (parentTabId set).
- store.handleEvent({
- type: "tab-created",
- id: "sub",
- title: "Sub",
- keyId: null,
- modelId: null,
- parentTabId: a.id,
- });
- store.reorderTabs([b.id, a.id]);
- const ids = store.tabs.map((t) => t.id);
- expect(ids).toEqual([b.id, a.id, "sub"]);
- });
-});
-
-describe("tabStore — rename + auto-title guard", () => {
- it("renameTab sets the title and persists it", async () => {
- const calls: Array<{ url: string; method?: string; body: string }> = [];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string, opts?: { method?: string; body?: string }) => {
- calls.push({ url, method: opts?.method, body: opts?.body ?? "" });
- return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
- }),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- store.renameTab(a.id, " My Tab ");
- expect(store.tabs[0]?.title).toBe("My Tab");
- expect(store.tabs[0]?.manualTitle).toBe(true);
- const patch = calls.find(
- (call) => call.url.endsWith(`/tabs/${a.id}`) && call.method === "PATCH",
- );
- expect(JSON.parse(patch?.body ?? "{}")).toEqual({ title: "My Tab" });
- });
-
- it("renameTab ignores an empty/whitespace name", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- store.renameTab(a.id, " ");
- expect(store.tabs[0]?.title).toBe("New Tab");
- expect(store.tabs[0]?.manualTitle).toBe(false);
- });
-
- it("sendMessage does NOT auto-title a manually renamed tab", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })),
- );
- const store = createTabStore();
- await store.createNewTab();
- store.renameTab(store.tabs[0]?.id ?? "", "Keep Me");
- await store.sendMessage("hello there this is the first message");
- expect(store.tabs[0]?.title).toBe("Keep Me");
- });
-
- it("sendMessage still auto-titles a tab that was never renamed", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })),
- );
- const store = createTabStore();
- await store.createNewTab();
- await store.sendMessage("first message becomes the title");
- expect(store.tabs[0]?.title).toBe("first message becomes the title");
- expect(store.tabs[0]?.manualTitle).toBe(false);
- });
-});
-
-describe("tabStore — per-tab chat input draft", () => {
- it("stores drafts per tab and restores them on switch", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
-
- store.switchTab(a.id);
- store.setDraft(a.id, "draft for A");
- store.switchTab(b.id);
- store.setDraft(b.id, "draft for B");
-
- // Active tab is B → its draft is exposed.
- expect(store.activeTab?.draft).toBe("draft for B");
- // Switching back to A restores A's draft without clobbering B's.
- store.switchTab(a.id);
- expect(store.activeTab?.draft).toBe("draft for A");
- expect(store.tabs.find((t) => t.id === b.id)?.draft).toBe("draft for B");
- });
-
- it("new tabs start with an empty draft and setDraft no-ops for unknown tabs", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- expect(a.draft).toBe("");
- store.setDraft("nope", "ignored"); // unknown tab → no throw, no effect
- expect(store.tabs.every((t) => t.draft === "")).toBe(true);
- });
-});
-
-describe("tabStore — image/pdf attachments", () => {
- function imgAttachment(id: string) {
- return { id, kind: "image" as const, mediaType: "image/png", data: "QQ==" };
- }
-
- it("stages attachments and reconciles them against intact draft tokens", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- store.switchTab(a.id);
-
- store.addAttachment(a.id, imgAttachment("aaaaaa"));
- // Draft carries the token → attachment survives.
- store.setDraft(a.id, "look 【image:aaaaaa】");
- expect(store.activeTab?.attachments.map((x) => x.id)).toEqual(["aaaaaa"]);
-
- // Remove the token from the draft → attachment is detached.
- store.setDraft(a.id, "look ");
- expect(store.activeTab?.attachments).toHaveLength(0);
- });
-
- it("sendMessage posts ordered multimodal content and clears the draft", async () => {
- const fetchMock = vi.fn((url: string) => {
- if (typeof url === "string" && url.endsWith("/chat")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) });
- }
- return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const store = createTabStore();
- const a = await store.createNewTab();
- store.switchTab(a.id);
-
- await store.sendMessage("here is A: [image]", [
- { type: "text", text: "here is A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ]);
-
- const chatCall = fetchMock.mock.calls.find(
- (c) => typeof c[0] === "string" && (c[0] as string).endsWith("/chat"),
- );
- expect(chatCall).toBeDefined();
- const body = JSON.parse((chatCall?.[1] as { body: string }).body);
- expect(body.message).toBe("here is A: [image]");
- expect(body.content).toEqual([
- { type: "text", text: "here is A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ]);
- });
-
- it("sendMessage omits content for a plain-text message", async () => {
- const fetchMock = vi.fn((url: string) => {
- if (typeof url === "string" && url.endsWith("/chat")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) });
- }
- return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const store = createTabStore();
- await store.createNewTab();
- await store.sendMessage("just text");
-
- const chatCall = fetchMock.mock.calls.find(
- (c) => typeof c[0] === "string" && (c[0] as string).endsWith("/chat"),
- );
- const body = JSON.parse((chatCall?.[1] as { body: string }).body);
- expect(body.content).toBeUndefined();
- });
-});
diff --git a/packages/frontend/tests/context-window.test.ts b/packages/frontend/tests/context-window.test.ts
deleted file mode 100644
index bb64ed5..0000000
--- a/packages/frontend/tests/context-window.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { computeContextUsage } from "../src/lib/context-window.js";
-import type { CacheStats } from "../src/lib/types.js";
-
-function stats(last: CacheStats["last"]): CacheStats {
- return {
- inputTokens: 0,
- outputTokens: 0,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- requests: last ? 1 : 0,
- last,
- };
-}
-
-describe("computeContextUsage", () => {
- it("derives current context from the LAST request's input + output", () => {
- const usage = computeContextUsage(
- stats({
- inputTokens: 47000,
- outputTokens: 1200,
- cacheReadTokens: 40000,
- cacheWriteTokens: 0,
- }),
- 200000,
- );
- // 47000 + 1200 — NOT the cumulative totals, and cache tokens are already
- // inside inputTokens (not re-added).
- expect(usage.current).toBe(48200);
- expect(usage.max).toBe(200000);
- expect(usage.percent).toBeCloseTo(24.1, 5); // 48200 / 200000 * 100, unrounded
- });
-
- it("returns max=null and percent=null when the limit is unknown", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- null,
- );
- expect(usage.current).toBe(100);
- expect(usage.max).toBeNull();
- expect(usage.percent).toBeNull();
- });
-
- it("treats a non-positive limit as unknown", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- 0,
- );
- expect(usage.max).toBeNull();
- expect(usage.percent).toBeNull();
- });
-
- it("reports zero usage when no request has completed yet", () => {
- expect(computeContextUsage(null, 200000)).toEqual({
- current: 0,
- max: 200000,
- percent: 0,
- });
- expect(computeContextUsage(stats(null), 200000)).toEqual({
- current: 0,
- max: 200000,
- percent: 0,
- });
- });
-
- it("clamps percent to 100 when context overflows the window", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 250000, outputTokens: 5000, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- 200000,
- );
- expect(usage.current).toBe(255000);
- expect(usage.percent).toBe(100);
- });
-
- it("keeps an unrounded percent so the UI can show 2 decimals", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 3690, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- 1000000,
- );
- // 3690 / 1,000,000 * 100 = 0.369 → displayed as "0.37%" (toFixed(2)).
- expect(usage.percent).toBeCloseTo(0.369, 6);
- expect((usage.percent as number).toFixed(2)).toBe("0.37");
- });
-});
diff --git a/packages/frontend/tests/sidebar-storage.test.ts b/packages/frontend/tests/sidebar-storage.test.ts
deleted file mode 100644
index 76815b3..0000000
--- a/packages/frontend/tests/sidebar-storage.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-/**
- * Tests for `src/lib/sidebar-storage.ts` — the localStorage round-trip
- * for the sidebar panel layout (`panels[].selected`).
- *
- * Bun's `localStorage` shim is partial (`getItem` is missing on a fresh
- * `globalThis`), so we install a clean in-memory polyfill per-test
- * rather than relying on whatever environment-default exists.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { loadSidebarPanels, saveSidebarPanels } from "../src/lib/sidebar-storage.js";
-
-const LS_KEY = "dispatch-sidebar-panels";
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-beforeEach(() => {
- vi.stubGlobal("localStorage", makeLocalStorageMock());
-});
-
-describe("loadSidebarPanels", () => {
- it("returns the default single-panel layout when localStorage is empty", () => {
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the parsed array when valid JSON is stored", () => {
- localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", "Skills", "Tools"]));
- expect(loadSidebarPanels()).toEqual(["Tasks", "Skills", "Tools"]);
- });
-
- it("preserves order across the round-trip (storage is render-order)", () => {
- const layout = ["Settings", "Chat Settings", "Key Usage", "Config"];
- localStorage.setItem(LS_KEY, JSON.stringify(layout));
- expect(loadSidebarPanels()).toEqual(layout);
- });
-
- it("returns the default when the stored JSON is malformed", () => {
- localStorage.setItem(LS_KEY, "not valid json {[");
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the default when the stored value is a non-array JSON value", () => {
- localStorage.setItem(LS_KEY, JSON.stringify({ panels: ["Tasks"] }));
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the default when the stored value is a JSON null", () => {
- localStorage.setItem(LS_KEY, "null");
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("filters out non-string array entries while keeping the valid ones", () => {
- localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", 42, null, "Skills", true, undefined]));
- expect(loadSidebarPanels()).toEqual(["Tasks", "Skills"]);
- });
-
- it("returns the default when filtering leaves an empty array", () => {
- // Preserves the SidebarPanel "minimum one panel" invariant (the
- // remove-button is hidden on `idx === 0` so the UI can't drop
- // below one panel; load must match).
- localStorage.setItem(LS_KEY, JSON.stringify([1, 2, false, null]));
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the default when localStorage.getItem throws (SecurityError etc.)", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => {
- throw new Error("SecurityError: storage disabled");
- },
- setItem: () => {},
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns a fresh array on each call (callers can mutate the result safely)", () => {
- const a = loadSidebarPanels();
- const b = loadSidebarPanels();
- expect(a).toEqual(b);
- expect(a).not.toBe(b);
- a.push("Tasks");
- expect(b).toEqual(["Chat Settings"]);
- });
-});
-
-describe("saveSidebarPanels", () => {
- it("writes the array as JSON under the canonical key", () => {
- saveSidebarPanels(["Chat Settings", "Tasks"]);
- const raw = localStorage.getItem(LS_KEY);
- expect(raw).toBe(JSON.stringify(["Chat Settings", "Tasks"]));
- });
-
- it("round-trips through load to recover the same value", () => {
- const layout = ["Chat Settings", "Skills", "Tools", "Config"];
- saveSidebarPanels(layout);
- expect(loadSidebarPanels()).toEqual(layout);
- });
-
- it("silently ignores storage errors (quota exceeded, SecurityError, etc.)", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(() => saveSidebarPanels(["Tasks"])).not.toThrow();
- });
-
- it("overwrites a prior layout (no append semantics)", () => {
- saveSidebarPanels(["Tasks", "Skills"]);
- saveSidebarPanels(["Config"]);
- expect(loadSidebarPanels()).toEqual(["Config"]);
- });
-
- it("can save an empty array (load will fall back to default on next read)", () => {
- // We don't refuse empty saves at the save site — the layout
- // component enforces the minimum-one-panel invariant by hiding
- // the remove-button on idx 0. If somehow an empty array is
- // passed, we store it; load substitutes the default on read.
- saveSidebarPanels([]);
- expect(localStorage.getItem(LS_KEY)).toBe("[]");
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-});
diff --git a/packages/frontend/tests/snapshot-sequencer.test.ts b/packages/frontend/tests/snapshot-sequencer.test.ts
deleted file mode 100644
index f2c5b8e..0000000
--- a/packages/frontend/tests/snapshot-sequencer.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { SnapshotSequencer } from "../src/lib/snapshot-sequencer.js";
-
-describe("SnapshotSequencer", () => {
- it("accepts the first response unconditionally", () => {
- const s = new SnapshotSequencer();
- const seq = s.begin();
- expect(s.accept(seq)).toBe(true);
- });
-
- it("accepts responses in send order", () => {
- const s = new SnapshotSequencer();
- const a = s.begin();
- const b = s.begin();
- const c = s.begin();
- expect(s.accept(a)).toBe(true);
- expect(s.accept(b)).toBe(true);
- expect(s.accept(c)).toBe(true);
- });
-
- it("rejects an older response that arrives AFTER a newer one (the core race)", () => {
- // Sequence: user clicks hour 9 (A), then hour 10 (B). B arrives first.
- const s = new SnapshotSequencer();
- const a = s.begin(); // toggle hour 9
- const b = s.begin(); // toggle hour 10
-
- // B arrives first — applied.
- expect(s.accept(b)).toBe(true);
-
- // A arrives later — must be dropped, else the snapshot from B (which
- // knows about both 9 and 10) gets overwritten by A's stale snapshot
- // (which only knows about 9), and hour 10 vanishes from the UI.
- expect(s.accept(a)).toBe(false);
- });
-
- it("rejects ALL straggler responses once a newer one wins", () => {
- const s = new SnapshotSequencer();
- const a = s.begin();
- const b = s.begin();
- const c = s.begin();
- expect(s.accept(c)).toBe(true);
- expect(s.accept(b)).toBe(false);
- expect(s.accept(a)).toBe(false);
- });
-
- it("handles the initial-load vs first-click race", () => {
- // On mount: $effect fires loadFromServer (seq=1).
- // Before it lands, user clicks a hour (seq=2).
- const s = new SnapshotSequencer();
- const initial = s.begin();
- const click = s.begin();
-
- // Click response arrives first — applied.
- expect(s.accept(click)).toBe(true);
- // Initial load straggles in — must be dropped (it pre-dates the click).
- expect(s.accept(initial)).toBe(false);
- });
-
- it("treats an equal seq as accept (idempotent re-arrival of the winner)", () => {
- const s = new SnapshotSequencer();
- const a = s.begin();
- expect(s.accept(a)).toBe(true);
- // Defensive: same seq accepted again (shouldn't happen in practice
- // but the semantics must be 'no-op accept', not 'reject').
- expect(s.accept(a)).toBe(true);
- });
-
- it("seq numbers are monotonic and unique across begin() calls", () => {
- const s = new SnapshotSequencer();
- const seen = new Set<number>();
- let prev = 0;
- for (let i = 0; i < 100; i++) {
- const seq = s.begin();
- expect(seq).toBeGreaterThan(prev);
- expect(seen.has(seq)).toBe(false);
- seen.add(seq);
- prev = seq;
- }
- });
-
- it("state inspector reflects last-applied watermark", () => {
- const s = new SnapshotSequencer();
- expect(s.state).toEqual({ nextSeq: 0, latestApplied: 0 });
- const a = s.begin();
- const b = s.begin();
- s.accept(b);
- expect(s.state).toEqual({ nextSeq: 2, latestApplied: b });
- // A is too old now — accept() returns false and watermark doesn't move back.
- expect(s.accept(a)).toBe(false);
- expect(s.state.latestApplied).toBe(b);
- });
-});
diff --git a/packages/frontend/tests/theme.test.ts b/packages/frontend/tests/theme.test.ts
deleted file mode 100644
index 63a343d..0000000
--- a/packages/frontend/tests/theme.test.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * Tests for `src/lib/theme.ts` — the shared theme picker module.
- *
- * Covers the post-Gemini-review fix where `App.svelte` (boot apply)
- * and `SettingsPanel.svelte` (UI picker) used to hand-roll their own
- * defaults and could disagree. After consolidation, both call into
- * this module and the bug class is gone — these tests pin that
- * invariant.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { applyTheme, DEFAULT_THEME, loadStoredTheme, THEMES } from "../src/lib/theme.js";
-
-const LS_KEY = "dispatch-theme";
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-function makeDocumentMock(): { documentElement: { setAttribute: (k: string, v: string) => void } } {
- const attrs = new Map<string, string>();
- return {
- documentElement: {
- setAttribute: (k: string, v: string) => {
- attrs.set(k, v);
- },
- // expose for assertions
- // @ts-expect-error — test-only escape hatch
- _attrs: attrs,
- },
- };
-}
-
-beforeEach(() => {
- vi.stubGlobal("localStorage", makeLocalStorageMock());
- vi.stubGlobal("document", makeDocumentMock());
-});
-
-describe("DEFAULT_THEME", () => {
- it("is one of the THEMES (sanity check that they can't drift)", () => {
- expect((THEMES as readonly string[]).includes(DEFAULT_THEME)).toBe(true);
- });
-});
-
-describe("loadStoredTheme", () => {
- it("returns DEFAULT_THEME when localStorage is empty (first-ever load)", () => {
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-
- it("returns the stored theme when it's a known theme", () => {
- localStorage.setItem(LS_KEY, "dracula");
- expect(loadStoredTheme()).toBe("dracula");
- });
-
- it("returns DEFAULT_THEME when the stored value isn't a known theme", () => {
- // Guards against a stale storage entry from a removed theme,
- // or a hand-edited bad value, falling through to daisyUI's own
- // fallback (which is `light`, not our DEFAULT).
- localStorage.setItem(LS_KEY, "solarized-rainbow");
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-
- it("returns DEFAULT_THEME when localStorage.getItem throws", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => {
- throw new Error("SecurityError");
- },
- setItem: () => {},
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-
- it("returns DEFAULT_THEME when localStorage is undefined (SSR)", () => {
- vi.stubGlobal("localStorage", undefined);
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-});
-
-describe("applyTheme", () => {
- it("writes data-theme on the document element", () => {
- applyTheme("nord");
- // @ts-expect-error — test mock exposes `_attrs`
- expect(document.documentElement._attrs.get("data-theme")).toBe("nord");
- });
-
- it("persists the theme to localStorage", () => {
- applyTheme("forest");
- expect(localStorage.getItem(LS_KEY)).toBe("forest");
- });
-
- it("round-trips through loadStoredTheme", () => {
- applyTheme("luxury");
- expect(loadStoredTheme()).toBe("luxury");
- });
-
- it("does not throw when localStorage.setItem throws (quota etc.)", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(() => applyTheme("cyberpunk")).not.toThrow();
- });
-
- it("still writes to the DOM even if localStorage write throws", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- applyTheme("coffee");
- // @ts-expect-error — test mock exposes `_attrs`
- expect(document.documentElement._attrs.get("data-theme")).toBe("coffee");
- });
-});
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
deleted file mode 100644
index 386cd8c..0000000
--- a/packages/frontend/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "declaration": false,
- "declarationMap": false,
- "types": ["vite/client"]
- },
- "include": ["src/**/*.ts", "src/**/*.svelte", "src/vite-env.d.ts"],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
deleted file mode 100644
index 84d1b0c..0000000
--- a/packages/frontend/vite.config.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { svelte } from "@sveltejs/vite-plugin-svelte";
-import tailwindcss from "@tailwindcss/vite";
-// Fallback only. ALWAYS prefer per-icon imports like:
-// import ListIcon from "phosphor-svelte/lib/ListIcon";
-// Named imports from "phosphor-svelte" pull in the full barrel and slow Vite
-// compilation. This plugin rewrites any stray named imports into per-icon
-// default imports at build/dev time, so we don't get crippled if an agent
-// forgets the correct pattern. Treat it as a safety net, not a license to
-// use named imports.
-import { sveltePhosphorOptimize } from "phosphor-svelte/vite";
-import { defineConfig } from "vite";
-
-export default defineConfig({
- base: "./",
- plugins: [tailwindcss(), sveltePhosphorOptimize(), svelte()],
- server: {
- port: 5173,
- allowedHosts: true,
- },
- test: {
- include: ["tests/**/*.test.ts"],
- },
-});
diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD
deleted file mode 100644
index 323f9cc..0000000
--- a/packaging/PKGBUILD
+++ /dev/null
@@ -1,274 +0,0 @@
-# Maintainer: dispatch
-#
-# Split package: builds the base application once and produces three packages:
-# dispatch — application files + CLI wrappers + env configs
-# dispatch-systemd — systemd system units (run as a given user: dispatch-api@<user>)
-# dispatch-s6 — s6 service definitions for dispatch-api / dispatch-frontend
-#
-# The Electron desktop wrapper lives in a separate PKGBUILD at packaging/electron/.
-#
-# Build with:
-# bin/build-pkg # makepkg -fd in packaging/
-
-pkgbase=dispatch
-pkgname=('dispatch' 'dispatch-systemd' 'dispatch-s6' 'code-search')
-pkgver=0.0.1
-pkgrel=1
-arch=('x86_64')
-url='https://github.com/anomalyco/dispatch'
-license=('MIT')
-makedepends=('bun' 'go' 'git')
-
-# Pinned cs (code spelunker) commit (tag v3.1.0) built for the search_code tool.
-# Kept in lockstep with the Docker build (see Dockerfile / Dockerfile.dev).
-_cs_commit=697e0bf194bbc7a4a877e5170c70618989fc92e7
-# All static files are read directly from ${_projectdir}/packaging/. We don't
-# use source=() because two of the s6 files share basenames (run, type), which
-# would collide inside ${srcdir}.
-source=()
-sha256sums=()
-
-_projectdir="${startdir}/.."
-_packagingdir="${_projectdir}/packaging"
-
-prepare() {
- mkdir -p "${srcdir}/dispatch-${pkgver}"
-
- # Copy project files into the src build directory (preserve symlinks)
- cp -a \
- "${_projectdir}/packages" \
- "${_projectdir}/package.json" \
- "${_projectdir}/bun.lock" \
- "${_projectdir}/tsconfig.base.json" \
- "${srcdir}/dispatch-${pkgver}/"
-
- if [ -f "${_projectdir}/dispatch.toml" ]; then
- cp "${_projectdir}/dispatch.toml" "${srcdir}/dispatch-${pkgver}/"
- fi
-}
-
-build() {
- cd "${srcdir}/dispatch-${pkgver}"
-
- # Install all deps (including devDependencies for vite build)
- bun install --frozen-lockfile
-
- # Build the frontend with production API port
- VITE_API_URL="${VITE_API_URL:-http://localhost:18390}" \
- bun run --cwd packages/frontend build
-
- # Slim node_modules for runtime
- rm -rf node_modules
- bun install --frozen-lockfile --production
-
- # --- Provide the patched `cs` code-search binary for the search_code tool ---
- # Building cs means a network clone + Go compile every run, which is wasted
- # work when a matching cs is already on the system: the code-search package
- # owns /usr/bin/cs and is pinned to the same _cs_commit. If it's already
- # installed, reuse that binary instead of recloning/recompiling. Force a
- # fresh build with DISPATCH_FORCE_CS_BUILD=1 (required when bumping
- # _cs_commit, since the installed binary won't reflect the new commit).
- if [ -z "${DISPATCH_FORCE_CS_BUILD:-}" ] && pacman -Qq code-search &>/dev/null \
- && [ -x /usr/bin/cs ]; then
- echo "cs: code-search already installed — reusing /usr/bin/cs" \
- "(set DISPATCH_FORCE_CS_BUILD=1 to rebuild)"
- cp /usr/bin/cs "${srcdir}/cs"
- else
- # Clone the pinned cs commit, apply the Luau declaration + fuzzy-distance
- # patches, and build a statically-linked binary. cs vendors its deps, so
- # `go build -mod=vendor` needs no network beyond the clone. Mirrors the
- # Docker cs-builder stage.
- #
- # rm -rf first so a rerun (makepkg -e, or two invocations without -C)
- # that reuses $srcdir doesn't abort on "destination path already exists".
- # This PKGBUILD intentionally avoids source=() (the s6 service files
- # share basenames and would collide in $srcdir), so we clone here rather
- # than via makepkg's VCS source handling.
- rm -rf "${srcdir}/cs-src"
- git clone https://github.com/boyter/cs.git "${srcdir}/cs-src"
- cd "${srcdir}/cs-src"
- git checkout "${_cs_commit}"
- git apply "${_projectdir}/docker/cs/luau-declarations.patch"
- git apply "${_projectdir}/docker/cs/fuzzy-distance.patch"
- CGO_ENABLED=0 GOFLAGS=-mod=vendor GOPATH="${srcdir}/gopath" GOCACHE="${srcdir}/gocache" \
- go build -ldflags="-s -w" -o "${srcdir}/cs" .
- fi
- "${srcdir}/cs" --version
-}
-
-# ----------------------------------------------------------------------------
-# dispatch — application files, CLI wrappers, env configs
-# ----------------------------------------------------------------------------
-package_dispatch() {
- pkgdesc='AI Agent Dispatch — backend + frontend (application files)'
- depends=('bun')
- install=dispatch.install
- backup=(
- 'etc/dispatch/dispatch-api.conf'
- 'etc/dispatch/dispatch-frontend.conf'
- )
-
- cd "${srcdir}/dispatch-${pkgver}"
-
- local optdir="${pkgdir}/opt/dispatch"
-
- # --- Application files ---
-
- # API package (full source)
- install -dm755 "${optdir}/packages/api"
- cp -a packages/api/. "${optdir}/packages/api/"
-
- # Core package (full source)
- install -dm755 "${optdir}/packages/core"
- cp -a packages/core/. "${optdir}/packages/core/"
-
- # Frontend: built dist + serve script + package.json (for workspace resolution)
- install -dm755 "${optdir}/packages/frontend/dist"
- cp -a packages/frontend/dist/. "${optdir}/packages/frontend/dist/"
- install -Dm644 packages/frontend/serve.ts "${optdir}/packages/frontend/serve.ts"
- install -Dm644 packages/frontend/package.json "${optdir}/packages/frontend/package.json"
-
- # Runtime node_modules (preserve symlinks for bun workspaces)
- # Exclude prebuilt ARM64 .node binaries — strip (x86_64 host) can't process them.
- install -dm755 "${optdir}/node_modules"
- cp -a node_modules/. "${optdir}/node_modules/"
- find "${optdir}/node_modules" -path '*/prebuilds/linux-arm64/*.node' -delete
-
- # Root manifest + lockfile
- install -Dm644 package.json "${optdir}/package.json"
- install -Dm644 bun.lock "${optdir}/bun.lock"
-
- # Optional default config
- if [ -f dispatch.toml ]; then
- install -Dm644 dispatch.toml "${optdir}/dispatch.toml"
- fi
-
- # --- Environment configs (preserved across upgrades via backup=()) ---
- install -Dm644 \
- "${_packagingdir}/dispatch-api.conf" \
- "${pkgdir}/etc/dispatch/dispatch-api.conf"
- install -Dm644 \
- "${_packagingdir}/dispatch-frontend.conf" \
- "${pkgdir}/etc/dispatch/dispatch-frontend.conf"
-
- # --- CLI wrappers ---
- install -Dm755 \
- "${_packagingdir}/dispatch-api-wrapper.sh" \
- "${pkgdir}/usr/bin/dispatch-api"
- install -Dm755 \
- "${_packagingdir}/dispatch-frontend-wrapper.sh" \
- "${pkgdir}/usr/bin/dispatch-frontend"
-
- # --- License ---
- if [ -f "${_projectdir}/LICENSE" ]; then
- install -Dm644 "${_projectdir}/LICENSE" \
- "${pkgdir}/usr/share/licenses/dispatch/LICENSE"
- fi
-}
-
-# ----------------------------------------------------------------------------
-# dispatch-systemd — systemd system unit templates (run as a chosen user)
-#
-# These are *system* units (managed by PID 1), not user units, so they do not
-# depend on a per-user `[email protected]` manager. Each is a template keyed on
-# the username: `dispatch-api@tradam` runs as the `tradam` user via User=%i.
-# ----------------------------------------------------------------------------
-package_dispatch-systemd() {
- pkgdesc='Systemd system unit templates for the Dispatch API and Frontend services (run as dispatch-api@<user>)'
- depends=("dispatch=${pkgver}" 'systemd')
- conflicts=('dispatch-s6')
- install=dispatch-systemd.install
-
- install -Dm644 \
- "${_packagingdir}/[email protected]" \
- "${pkgdir}/usr/lib/systemd/system/[email protected]"
-
- install -Dm644 \
- "${_packagingdir}/[email protected]" \
- "${pkgdir}/usr/lib/systemd/system/[email protected]"
-}
-
-# ----------------------------------------------------------------------------
-# dispatch-s6 — s6 service definitions (for Artix / standalone s6)
-# ----------------------------------------------------------------------------
-package_dispatch-s6() {
- pkgdesc='s6-rc service definitions for the Dispatch API and Frontend services'
- depends=("dispatch=${pkgver}" 's6' 's6-rc')
- conflicts=('dispatch-systemd')
- install=dispatch-s6.install
-
- # s6-rc layout: each service is split into a `-srv` (the daemon) and a
- # `-log` (s6-log consumer), linked by producer-for/consumer-for/pipeline-name.
- # The pipeline-name lets `s6-rc -u change dispatch-api` bring both up together.
-
- # --- dispatch-api-srv ---
- install -Dm755 "${_packagingdir}/s6/dispatch-api-srv/run" \
- "${pkgdir}/etc/s6/sv/dispatch-api-srv/run"
- install -Dm644 "${_packagingdir}/s6/dispatch-api-srv/type" \
- "${pkgdir}/etc/s6/sv/dispatch-api-srv/type"
- install -Dm644 "${_packagingdir}/s6/dispatch-api-srv/producer-for" \
- "${pkgdir}/etc/s6/sv/dispatch-api-srv/producer-for"
-
- # --- dispatch-api-log ---
- install -Dm755 "${_packagingdir}/s6/dispatch-api-log/run" \
- "${pkgdir}/etc/s6/sv/dispatch-api-log/run"
- install -Dm644 "${_packagingdir}/s6/dispatch-api-log/type" \
- "${pkgdir}/etc/s6/sv/dispatch-api-log/type"
- install -Dm644 "${_packagingdir}/s6/dispatch-api-log/consumer-for" \
- "${pkgdir}/etc/s6/sv/dispatch-api-log/consumer-for"
- install -Dm644 "${_packagingdir}/s6/dispatch-api-log/pipeline-name" \
- "${pkgdir}/etc/s6/sv/dispatch-api-log/pipeline-name"
- # notification-fd must match the `-d3` flag in run; without it s6-log
- # aborts with "invalid notification fd" and the API blocks on a full
- # stdout pipe before it can listen().
- install -Dm644 "${_packagingdir}/s6/dispatch-api-log/notification-fd" \
- "${pkgdir}/etc/s6/sv/dispatch-api-log/notification-fd"
-
- # --- dispatch-frontend-srv ---
- install -Dm755 "${_packagingdir}/s6/dispatch-frontend-srv/run" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-srv/run"
- install -Dm644 "${_packagingdir}/s6/dispatch-frontend-srv/type" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-srv/type"
- install -Dm644 "${_packagingdir}/s6/dispatch-frontend-srv/producer-for" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-srv/producer-for"
-
- # --- dispatch-frontend-log ---
- install -Dm755 "${_packagingdir}/s6/dispatch-frontend-log/run" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-log/run"
- install -Dm644 "${_packagingdir}/s6/dispatch-frontend-log/type" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-log/type"
- install -Dm644 "${_packagingdir}/s6/dispatch-frontend-log/consumer-for" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-log/consumer-for"
- install -Dm644 "${_packagingdir}/s6/dispatch-frontend-log/pipeline-name" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-log/pipeline-name"
- # notification-fd must match the `-d3` flag in run (see dispatch-api-log).
- install -Dm644 "${_packagingdir}/s6/dispatch-frontend-log/notification-fd" \
- "${pkgdir}/etc/s6/sv/dispatch-frontend-log/notification-fd"
-}
-
-
-# ----------------------------------------------------------------------------
-# code-search — patched `cs` (code spelunker) binary
-#
-# A standalone, relevance-ranked code search CLI (github.com/boyter/cs), built
-# from a pinned commit with an added Luau declaration table (see
-# docker/cs/luau-declarations.patch). Powers Dispatch's `search_code` agent
-# tool, but is a self-contained binary usable on its own. Built once in build()
-# and installed to /usr/bin/cs.
-# ----------------------------------------------------------------------------
-package_code-search() {
- pkgdesc='code spelunker (cs) — fast, relevance-ranked code search CLI (patched for Luau)'
- depends=()
- # Both this package and the unrelated AUR `cs` (a colored ls) own /usr/bin/cs;
- # declare the conflict so pacman reports it cleanly instead of a raw file
- # collision. We do NOT `provides=('cs')` — this is a different program.
- conflicts=('cs')
- url='https://github.com/boyter/cs'
-
- install -Dm755 "${srcdir}/cs" "${pkgdir}/usr/bin/cs"
-
- if [ -f "${srcdir}/cs-src/LICENSE" ]; then
- install -Dm644 "${srcdir}/cs-src/LICENSE" \
- "${pkgdir}/usr/share/licenses/code-search/LICENSE"
- fi
-}
diff --git a/packaging/dispatch-api-wrapper.sh b/packaging/dispatch-api-wrapper.sh
deleted file mode 100755
index d8c656b..0000000
--- a/packaging/dispatch-api-wrapper.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/bash
-# dispatch-api-wrapper.sh
-# Wrapper script for running the Dispatch API outside of systemd.
-# Install to /usr/bin/dispatch-api (chmod 755).
-#
-# Usage:
-# dispatch-api # runs the server, inheriting the current environment
-# dispatch-api --help # passes flags through to bun
-
-set -euo pipefail
-
-DISPATCH_CONF="/etc/dispatch/dispatch-api.conf"
-DISPATCH_DIR="/opt/dispatch"
-BUN="/usr/bin/bun"
-ENTRY_POINT="packages/api/src/index.ts"
-
-# Source the environment file if it exists
-if [[ -f "$DISPATCH_CONF" ]]; then
- # Export every variable defined in the conf file so child processes see them.
- # Lines starting with '#' and blank lines are skipped automatically by bash's
- # 'source' builtin.
- set -o allexport
- # shellcheck source=/etc/dispatch/dispatch-api.conf
- source "$DISPATCH_CONF"
- set +o allexport
-fi
-
-# Change to the application directory
-cd "$DISPATCH_DIR"
-
-# Hand off to bun — exec replaces this process so signals are forwarded correctly
-exec "$BUN" "$ENTRY_POINT" "$@"
diff --git a/packaging/dispatch-api.conf b/packaging/dispatch-api.conf
deleted file mode 100644
index 9824e16..0000000
--- a/packaging/dispatch-api.conf
+++ /dev/null
@@ -1,31 +0,0 @@
-# /etc/dispatch/dispatch-api.conf
-# Environment configuration for the Dispatch API service.
-# This file is sourced by systemd (via EnvironmentFile=) and by the
-# dispatch-api-wrapper.sh script for manual invocation.
-#
-# Lines beginning with '#' are comments and are ignored.
-# Syntax: KEY=value (no spaces around '=', no 'export' keyword needed for systemd)
-
-# ---------------------------------------------------------------------------
-# LLM / AI provider
-# ---------------------------------------------------------------------------
-
-# API key used by the backend to call the OpenCode (or compatible) LLM API.
-OPENCODE_API_KEY=
-
-# ---------------------------------------------------------------------------
-# Runtime environment
-# ---------------------------------------------------------------------------
-
-# Set to "production" for live deployments.
-NODE_ENV=production
-
-# ---------------------------------------------------------------------------
-# Server
-# ---------------------------------------------------------------------------
-
-# Port the API listens on (default: 3000).
-PORT=18390
-
-# Hostname/address to bind to (default: 0.0.0.0).
-# HOST=0.0.0.0
diff --git a/packaging/[email protected] b/packaging/[email protected]
deleted file mode 100644
index e1946dd..0000000
--- a/packaging/[email protected]
+++ /dev/null
@@ -1,21 +0,0 @@
-[Unit]
-Description=Dispatch API server (user %i)
-After=network.target local-fs.target home.mount
-# The API stores its SQLite DB and reads Claude credentials under the user's
-# home (~/.local/share/dispatch). If /home is a separate filesystem, starting
-# before it is mounted makes credential discovery fail (EACCES) and Claude
-# tabs fall back to an empty API key (401). Ensure /home is mounted first.
-RequiresMountsFor=/home
-
-[Service]
-Type=simple
-User=%i
-Environment=NODE_ENV=production
-Environment=PORT=18390
-WorkingDirectory=/opt/dispatch
-ExecStart=/usr/bin/bun packages/api/src/index.ts
-Restart=on-failure
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
diff --git a/packaging/dispatch-frontend-wrapper.sh b/packaging/dispatch-frontend-wrapper.sh
deleted file mode 100755
index 06aa9b0..0000000
--- a/packaging/dispatch-frontend-wrapper.sh
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/bin/bash
-# dispatch-frontend-wrapper.sh
-# Wrapper script for running the Dispatch Frontend static-file server
-# outside of systemd / s6.
-# Install to /usr/bin/dispatch-frontend (chmod 755).
-#
-# Usage:
-# dispatch-frontend # runs the server, inheriting the current environment
-# dispatch-frontend --help # passes flags through to bun
-
-set -euo pipefail
-
-DISPATCH_CONF="/etc/dispatch/dispatch-frontend.conf"
-DISPATCH_DIR="/opt/dispatch"
-BUN="/usr/bin/bun"
-ENTRY_POINT="packages/frontend/serve.ts"
-
-# Source the environment file if it exists
-if [[ -f "$DISPATCH_CONF" ]]; then
- set -o allexport
- # shellcheck source=/etc/dispatch/dispatch-frontend.conf
- source "$DISPATCH_CONF"
- set +o allexport
-fi
-
-# Change to the application directory
-cd "$DISPATCH_DIR"
-
-# Hand off to bun — exec replaces this process so signals are forwarded correctly
-exec "$BUN" "$ENTRY_POINT" "$@"
diff --git a/packaging/dispatch-frontend.conf b/packaging/dispatch-frontend.conf
deleted file mode 100644
index 37d2984..0000000
--- a/packaging/dispatch-frontend.conf
+++ /dev/null
@@ -1,21 +0,0 @@
-# /etc/dispatch/dispatch-frontend.conf
-# Environment configuration for the Dispatch Frontend static-file server.
-# Sourced by systemd (EnvironmentFile=), the s6 run script, and the
-# dispatch-frontend-wrapper.sh script for manual invocation.
-#
-# Lines beginning with '#' are comments and are ignored.
-# Syntax: KEY=value (no spaces around '=', no 'export' keyword needed for systemd)
-
-# ---------------------------------------------------------------------------
-# Server
-# ---------------------------------------------------------------------------
-
-# Port the static file server listens on (default: 18391).
-PORT=18391
-
-# Interface to bind to (default: 0.0.0.0).
-# HOST=0.0.0.0
-
-# Directory containing the built frontend assets.
-# Defaults to /opt/dispatch/packages/frontend/dist
-# DIST_DIR=/opt/dispatch/packages/frontend/dist
diff --git a/packaging/[email protected] b/packaging/[email protected]
deleted file mode 100644
index 6f35cc1..0000000
--- a/packaging/[email protected]
+++ /dev/null
@@ -1,22 +0,0 @@
-# Dispatch Frontend — system service template.
-# Runs under the system manager (PID 1) but drops privileges to the user named
-# in the instance: `dispatch-frontend@tradam` runs as the `tradam` user.
-# Tied to the matching API instance for the same user.
-#
-# Enable/start:
-# sudo systemctl enable --now dispatch-frontend@<user>
-[Unit]
-Description=Dispatch Frontend (static file server, running as %i)
-After=dispatch-api@%i.service
-
-[Service]
-Type=simple
-User=%i
-WorkingDirectory=/opt/dispatch
-ExecStart=/usr/bin/bun packages/frontend/serve.ts
-EnvironmentFile=-/etc/dispatch/dispatch-frontend.conf
-Restart=on-failure
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
diff --git a/packaging/dispatch-s6.install b/packaging/dispatch-s6.install
deleted file mode 100644
index efa4c9b..0000000
--- a/packaging/dispatch-s6.install
+++ /dev/null
@@ -1,46 +0,0 @@
-post_install() {
- install -dm755 -o tradam -g tradam /var/log/dispatch-api
- install -dm755 -o tradam -g tradam /var/log/dispatch-frontend
-
- echo ""
- echo "==> Dispatch s6-rc services installed at /etc/s6/sv/"
- echo " Service pipelines: dispatch-api (= dispatch-api-srv | dispatch-api-log)"
- echo " dispatch-frontend (= dispatch-frontend-srv | dispatch-frontend-log)"
- echo " To enable and start:"
- echo " s6 repository sync"
- echo " s6 set enable -I pull dispatch-api-srv dispatch-api-log"
- echo " s6 set enable -I pull dispatch-frontend-srv dispatch-frontend-log"
- echo " s6 set commit -f"
- echo " s6 live install"
- echo " s6-rc -u change dispatch-api"
- echo " s6-rc -u change dispatch-frontend"
- echo ""
-}
-
-post_upgrade() {
- install -dm755 -o tradam -g tradam /var/log/dispatch-api 2>/dev/null || true
- install -dm755 -o tradam -g tradam /var/log/dispatch-frontend 2>/dev/null || true
-
- # Remove any stale legacy service dirs from the old (pre-srv/-log split)
- # layout, which would otherwise confuse s6 repository sync.
- for legacy in /etc/s6/sv/dispatch-api /etc/s6/sv/dispatch-frontend; do
- if [ -d "$legacy" ] && [ -d "$legacy/log" ] && [ ! -e "$legacy/producer-for" ]; then
- rm -rf "$legacy"
- fi
- done
-
- echo ""
- echo "==> Dispatch s6 services upgraded."
- echo " Restart:"
- echo " s6-rc -d change dispatch-api && s6-rc -u change dispatch-api"
- echo " s6-rc -d change dispatch-frontend && s6-rc -u change dispatch-frontend"
- echo ""
-}
-
-pre_remove() {
- echo ""
- echo "==> Stopping dispatch services..."
- s6-rc -d change dispatch-api 2>/dev/null || true
- s6-rc -d change dispatch-frontend 2>/dev/null || true
- echo ""
-}
diff --git a/packaging/dispatch-systemd.install b/packaging/dispatch-systemd.install
deleted file mode 100644
index 5ebf261..0000000
--- a/packaging/dispatch-systemd.install
+++ /dev/null
@@ -1,25 +0,0 @@
-post_install() {
- echo ""
- echo "==> Dispatch systemd system unit templates installed."
- echo " These run as the user named in the instance (replace <user>)."
- echo " Enable and start (e.g. for user 'tradam'):"
- echo " sudo systemctl daemon-reload"
- echo " sudo systemctl enable --now dispatch-api@<user> dispatch-frontend@<user>"
- echo ""
-}
-
-post_upgrade() {
- echo ""
- echo "==> Dispatch systemd units upgraded."
- echo " Reload and restart your instances (replace <user>):"
- echo " sudo systemctl daemon-reload"
- echo " sudo systemctl restart dispatch-api@<user> dispatch-frontend@<user>"
- echo ""
-}
-
-pre_remove() {
- echo ""
- echo "==> Stop dispatch services before removal (replace <user>):"
- echo " sudo systemctl disable --now dispatch-api@<user> dispatch-frontend@<user>"
- echo ""
-}
diff --git a/packaging/dispatch.install b/packaging/dispatch.install
deleted file mode 100644
index 75b36cc..0000000
--- a/packaging/dispatch.install
+++ /dev/null
@@ -1,31 +0,0 @@
-post_install() {
- echo ""
- echo "==> Dispatch (base) has been installed."
- echo " Application files: /opt/dispatch"
- echo " Config:"
- echo " /etc/dispatch/dispatch-api.conf"
- echo " /etc/dispatch/dispatch-frontend.conf"
- echo ""
- echo " Run manually:"
- echo " dispatch-api # backend (port 18390 by default)"
- echo " dispatch-frontend # static frontend (port 18391 by default)"
- echo ""
- echo " To run as a service, install one of:"
- echo " pacman -S dispatch-systemd # systemd system services (run as your user)"
- echo " pacman -S dispatch-s6 # s6 services (Artix)"
- echo ""
- echo " For the Electron desktop wrapper:"
- echo " pacman -S dispatch-electron"
- echo ""
-}
-
-post_upgrade() {
- echo ""
- echo "==> Dispatch (base) upgraded."
- echo " If you use dispatch-systemd, restart the units (replace <user>):"
- echo " sudo systemctl restart dispatch-api@<user> dispatch-frontend@<user>"
- echo " If you use dispatch-s6, restart the services:"
- echo " s6-svc -r /run/service/dispatch-api"
- echo " s6-svc -r /run/service/dispatch-frontend"
- echo ""
-}
diff --git a/packaging/electron/PKGBUILD b/packaging/electron/PKGBUILD
deleted file mode 100644
index a259418..0000000
--- a/packaging/electron/PKGBUILD
+++ /dev/null
@@ -1,100 +0,0 @@
-# Maintainer: dispatch
-#
-# Electron desktop wrapper for Dispatch on Linux.
-#
-# Self-contained: bundles its own copy of the built frontend dist + electron
-# entry points under /opt/dispatch-electron/. Does NOT depend on the `dispatch`
-# package — install that separately (with dispatch-systemd or dispatch-s6) if
-# you want to run the backend locally, or point VITE_API_URL at a remote
-# instance at build time.
-#
-# Build with:
-# bin/build-pkg-electron # makepkg -fd in packaging/electron/
-#
-# Override the backend URL the bundled frontend talks to:
-# VITE_API_URL="http://your-host:18390" bin/build-pkg-electron
-
-pkgname=dispatch-electron
-pkgver=0.0.1
-pkgrel=1
-pkgdesc='Electron desktop wrapper for Dispatch (self-contained)'
-arch=('x86_64')
-url='https://github.com/anomalyco/dispatch'
-license=('MIT')
-depends=('electron')
-makedepends=('bun')
-install=dispatch-electron.install
-source=()
-sha256sums=()
-
-_projectdir="${startdir}/../.."
-_packagingdir="${_projectdir}/packaging"
-_electrondir="${_packagingdir}/electron"
-
-prepare() {
- mkdir -p "${srcdir}/dispatch-electron-${pkgver}"
-
- # Copy project files into the src build directory (preserve symlinks)
- cp -a \
- "${_projectdir}/packages" \
- "${_projectdir}/package.json" \
- "${_projectdir}/bun.lock" \
- "${_projectdir}/tsconfig.base.json" \
- "${srcdir}/dispatch-electron-${pkgver}/"
-}
-
-build() {
- cd "${srcdir}/dispatch-electron-${pkgver}"
-
- # Install all deps (including devDependencies for vite build)
- bun install --frozen-lockfile
-
- # Build the SPA. VITE_API_URL is baked in at build time.
- VITE_API_URL="${VITE_API_URL:-http://localhost:18390}" \
- bun run --cwd packages/frontend build
-}
-
-package() {
- cd "${srcdir}/dispatch-electron-${pkgver}"
-
- local optdir="${pkgdir}/opt/dispatch-electron"
-
- # --- Bundled SPA dist ---
- install -dm755 "${optdir}/dist"
- cp -a packages/frontend/dist/. "${optdir}/dist/"
-
- # --- Electron entry points ---
- # main.cjs loads ../dist/index.html — so the dist must sit alongside electron/.
- install -Dm644 \
- packages/frontend/electron/main.cjs \
- "${optdir}/electron/main.cjs"
- install -Dm644 \
- packages/frontend/electron/preload.cjs \
- "${optdir}/electron/preload.cjs"
-
- # --- /usr/bin/dispatch (electron wrapper) ---
- install -Dm755 \
- "${_electrondir}/dispatch-electron-wrapper.sh" \
- "${pkgdir}/usr/bin/dispatch"
-
- # --- Desktop integration ---
- install -Dm644 \
- "${_electrondir}/dispatch.desktop" \
- "${pkgdir}/usr/share/applications/dispatch.desktop"
-
- install -Dm644 \
- "${_electrondir}/dispatch.svg" \
- "${pkgdir}/usr/share/icons/hicolor/scalable/apps/dispatch.svg"
-
- if [ -f "${_electrondir}/dispatch.png" ]; then
- install -Dm644 \
- "${_electrondir}/dispatch.png" \
- "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dispatch.png"
- fi
-
- # --- License ---
- if [ -f "${_projectdir}/LICENSE" ]; then
- install -Dm644 "${_projectdir}/LICENSE" \
- "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
- fi
-}
diff --git a/packaging/electron/dispatch-electron-wrapper.sh b/packaging/electron/dispatch-electron-wrapper.sh
deleted file mode 100755
index 948db3b..0000000
--- a/packaging/electron/dispatch-electron-wrapper.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/bash
-exec /usr/bin/electron --name="Dispatch" /opt/dispatch-electron/electron/main.cjs "$@"
diff --git a/packaging/electron/dispatch-electron.install b/packaging/electron/dispatch-electron.install
deleted file mode 100644
index 9eea6b1..0000000
--- a/packaging/electron/dispatch-electron.install
+++ /dev/null
@@ -1,32 +0,0 @@
-post_install() {
- echo ""
- echo "==> Dispatch Electron desktop wrapper installed (self-contained)."
- echo " Launch from your menu (Dispatch) or run:"
- echo " dispatch"
- echo ""
- echo " The bundled frontend talks to the API URL baked in at build"
- echo " time (default: http://localhost:18390). To run a local backend:"
- echo " pacman -S dispatch dispatch-systemd # systemd"
- echo " pacman -S dispatch dispatch-s6 # s6 / Artix"
- echo ""
- echo " To point the wrapper at a different backend, rebuild with:"
- echo " VITE_API_URL=\"http://your-host:18390\" bin/build-pkg-electron"
- echo ""
-
- if command -v update-desktop-database >/dev/null 2>&1; then
- update-desktop-database -q /usr/share/applications 2>/dev/null || true
- fi
- if command -v gtk-update-icon-cache >/dev/null 2>&1; then
- gtk-update-icon-cache -q /usr/share/icons/hicolor 2>/dev/null || true
- fi
-}
-
-post_upgrade() {
- post_install
-}
-
-post_remove() {
- if command -v update-desktop-database >/dev/null 2>&1; then
- update-desktop-database -q /usr/share/applications 2>/dev/null || true
- fi
-}
diff --git a/packaging/electron/dispatch.desktop b/packaging/electron/dispatch.desktop
deleted file mode 100644
index 0ed44af..0000000
--- a/packaging/electron/dispatch.desktop
+++ /dev/null
@@ -1,9 +0,0 @@
-[Desktop Entry]
-Name=Dispatch
-Comment=AI Agent Dispatch Interface
-Exec=/usr/bin/dispatch %U
-Icon=dispatch
-Terminal=false
-Type=Application
-Categories=Development;Utility;
-StartupWMClass=Dispatch
diff --git a/packaging/electron/dispatch.png b/packaging/electron/dispatch.png
deleted file mode 100644
index 84d743a..0000000
--- a/packaging/electron/dispatch.png
+++ /dev/null
Binary files differ
diff --git a/packaging/electron/dispatch.svg b/packaging/electron/dispatch.svg
deleted file mode 100644
index 2aae97f..0000000
--- a/packaging/electron/dispatch.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
- <defs>
- <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
- <stop offset="0%" style="stop-color:#5b6af0"/>
- <stop offset="100%" style="stop-color:#9b59f7"/>
- </linearGradient>
- <linearGradient id="arrow" x1="0%" y1="0%" x2="100%" y2="100%">
- <stop offset="0%" style="stop-color:#ffffff;stop-opacity:1"/>
- <stop offset="100%" style="stop-color:#e0d8ff;stop-opacity:1"/>
- </linearGradient>
- </defs>
-
- <!-- Rounded square background -->
- <rect x="2" y="2" width="60" height="60" rx="14" ry="14" fill="url(#bg)"/>
-
- <!-- Send/dispatch arrow icon: a paper-plane style arrow pointing upper-right -->
- <!-- Main arrow body -->
- <polygon points="10,50 54,10 38,32" fill="url(#arrow)" opacity="0.95"/>
- <!-- Arrow tail fill -->
- <polygon points="10,50 28,36 22,54" fill="url(#arrow)" opacity="0.75"/>
- <!-- Subtle highlight line -->
- <line x1="54" y1="10" x2="28" y2="36" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
-</svg>
diff --git a/packaging/s6/dispatch-api-log/consumer-for b/packaging/s6/dispatch-api-log/consumer-for
deleted file mode 100644
index 8a696f4..0000000
--- a/packaging/s6/dispatch-api-log/consumer-for
+++ /dev/null
@@ -1 +0,0 @@
-dispatch-api-srv
diff --git a/packaging/s6/dispatch-api-log/notification-fd b/packaging/s6/dispatch-api-log/notification-fd
deleted file mode 100644
index 00750ed..0000000
--- a/packaging/s6/dispatch-api-log/notification-fd
+++ /dev/null
@@ -1 +0,0 @@
-3
diff --git a/packaging/s6/dispatch-api-log/pipeline-name b/packaging/s6/dispatch-api-log/pipeline-name
deleted file mode 100644
index 9dab7e6..0000000
--- a/packaging/s6/dispatch-api-log/pipeline-name
+++ /dev/null
@@ -1 +0,0 @@
-dispatch-api
diff --git a/packaging/s6/dispatch-api-log/run b/packaging/s6/dispatch-api-log/run
deleted file mode 100644
index 5d1b821..0000000
--- a/packaging/s6/dispatch-api-log/run
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/sh
-# Logger for dispatch-api. Reads stdout from dispatch-api-srv and writes to
-# /var/log/dispatch-api/ using s6-log (auto-rotation, timestamping).
-
-exec /usr/bin/s6-setuidgid tradam \
- /usr/bin/s6-log -b -d3 -- n10 s10000000 T /var/log/dispatch-api
diff --git a/packaging/s6/dispatch-api-log/type b/packaging/s6/dispatch-api-log/type
deleted file mode 100644
index 5883cff..0000000
--- a/packaging/s6/dispatch-api-log/type
+++ /dev/null
@@ -1 +0,0 @@
-longrun
diff --git a/packaging/s6/dispatch-api-srv/producer-for b/packaging/s6/dispatch-api-srv/producer-for
deleted file mode 100644
index 88f7df2..0000000
--- a/packaging/s6/dispatch-api-srv/producer-for
+++ /dev/null
@@ -1 +0,0 @@
-dispatch-api-log
diff --git a/packaging/s6/dispatch-api-srv/run b/packaging/s6/dispatch-api-srv/run
deleted file mode 100644
index f9c3a67..0000000
--- a/packaging/s6/dispatch-api-srv/run
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/bin/sh
-# dispatch-api service — runs the Dispatch backend API as the `tradam` user.
-# stdout/stderr are piped to dispatch-api-log via the s6-rc pipeline.
-
-DISPATCH_CONF="/etc/dispatch/dispatch-api.conf"
-DISPATCH_DIR="/opt/dispatch"
-
-if [ -f "$DISPATCH_CONF" ]; then
- set -a
- . "$DISPATCH_CONF"
- set +a
-fi
-
-cd "$DISPATCH_DIR" || exit 1
-
-# Merge stderr into stdout so both get logged by the consumer.
-exec 2>&1
-
-# Wait for the home directory to be available before starting.
-#
-# On the cyberdeck /home is a separate filesystem (ext4 on /dev/sda4). If this
-# service starts before that mount is ready, the API runs as `tradam` with
-# HOME=/home/tradam while /home/tradam does not yet exist. Creating the data
-# dir (~/.local/share/dispatch) then fails with EACCES, Claude credential
-# discovery silently fails, and Claude tabs fall back to an empty API key
-# (401 from OpenCode Zen) for the entire lifetime of the process. Block until
-# the home directory appears (capped at ~30s as a safety net). Where /home is
-# part of the root filesystem this check passes immediately.
-i=0
-while [ ! -d "/home/tradam" ]; do
- i=$((i + 1))
- if [ "$i" -ge 30 ]; then
- echo "dispatch-api: /home/tradam still missing after ${i}s — starting anyway" >&2
- break
- fi
- echo "dispatch-api: waiting for /home/tradam to be available (${i})..." >&2
- sleep 1
-done
-
-# Drop privileges to tradam and run bun.
-exec /usr/bin/s6-setuidgid tradam \
- /usr/bin/env \
- HOME=/home/tradam \
- USER=tradam \
- LOGNAME=tradam \
- PATH=/usr/local/bin:/usr/bin:/bin \
- NODE_ENV="${NODE_ENV:-production}" \
- PORT="${PORT:-18390}" \
- OPENCODE_API_KEY="${OPENCODE_API_KEY:-}" \
- /usr/bin/bun packages/api/src/index.ts
diff --git a/packaging/s6/dispatch-api-srv/type b/packaging/s6/dispatch-api-srv/type
deleted file mode 100644
index 5883cff..0000000
--- a/packaging/s6/dispatch-api-srv/type
+++ /dev/null
@@ -1 +0,0 @@
-longrun
diff --git a/packaging/s6/dispatch-frontend-log/consumer-for b/packaging/s6/dispatch-frontend-log/consumer-for
deleted file mode 100644
index f941d04..0000000
--- a/packaging/s6/dispatch-frontend-log/consumer-for
+++ /dev/null
@@ -1 +0,0 @@
-dispatch-frontend-srv
diff --git a/packaging/s6/dispatch-frontend-log/notification-fd b/packaging/s6/dispatch-frontend-log/notification-fd
deleted file mode 100644
index 00750ed..0000000
--- a/packaging/s6/dispatch-frontend-log/notification-fd
+++ /dev/null
@@ -1 +0,0 @@
-3
diff --git a/packaging/s6/dispatch-frontend-log/pipeline-name b/packaging/s6/dispatch-frontend-log/pipeline-name
deleted file mode 100644
index 0ee914b..0000000
--- a/packaging/s6/dispatch-frontend-log/pipeline-name
+++ /dev/null
@@ -1 +0,0 @@
-dispatch-frontend
diff --git a/packaging/s6/dispatch-frontend-log/run b/packaging/s6/dispatch-frontend-log/run
deleted file mode 100644
index 4dda559..0000000
--- a/packaging/s6/dispatch-frontend-log/run
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/sh
-# Logger for dispatch-frontend. Reads stdout from dispatch-frontend-srv and
-# writes to /var/log/dispatch-frontend/ using s6-log.
-
-exec /usr/bin/s6-setuidgid tradam \
- /usr/bin/s6-log -b -d3 -- n10 s10000000 T /var/log/dispatch-frontend
diff --git a/packaging/s6/dispatch-frontend-log/type b/packaging/s6/dispatch-frontend-log/type
deleted file mode 100644
index 5883cff..0000000
--- a/packaging/s6/dispatch-frontend-log/type
+++ /dev/null
@@ -1 +0,0 @@
-longrun
diff --git a/packaging/s6/dispatch-frontend-srv/producer-for b/packaging/s6/dispatch-frontend-srv/producer-for
deleted file mode 100644
index c29eb09..0000000
--- a/packaging/s6/dispatch-frontend-srv/producer-for
+++ /dev/null
@@ -1 +0,0 @@
-dispatch-frontend-log
diff --git a/packaging/s6/dispatch-frontend-srv/run b/packaging/s6/dispatch-frontend-srv/run
deleted file mode 100644
index 3411476..0000000
--- a/packaging/s6/dispatch-frontend-srv/run
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/sh
-# dispatch-frontend service — serves the built frontend assets as the `tradam` user.
-# stdout/stderr are piped to dispatch-frontend-log via the s6-rc pipeline.
-
-DISPATCH_CONF="/etc/dispatch/dispatch-frontend.conf"
-DISPATCH_DIR="/opt/dispatch"
-
-if [ -f "$DISPATCH_CONF" ]; then
- set -a
- . "$DISPATCH_CONF"
- set +a
-fi
-
-cd "$DISPATCH_DIR" || exit 1
-
-exec 2>&1
-
-exec /usr/bin/s6-setuidgid tradam \
- /usr/bin/env \
- HOME=/home/tradam \
- USER=tradam \
- LOGNAME=tradam \
- PATH=/usr/local/bin:/usr/bin:/bin \
- PORT="${PORT:-18391}" \
- /usr/bin/bun packages/frontend/serve.ts
diff --git a/packaging/s6/dispatch-frontend-srv/type b/packaging/s6/dispatch-frontend-srv/type
deleted file mode 100644
index 5883cff..0000000
--- a/packaging/s6/dispatch-frontend-srv/type
+++ /dev/null
@@ -1 +0,0 @@
-longrun
diff --git a/research/ai-coding-assistants-established.md b/research/ai-coding-assistants-established.md
deleted file mode 100644
index 7833798..0000000
--- a/research/ai-coding-assistants-established.md
+++ /dev/null
@@ -1,345 +0,0 @@
-# Subagent Report: Established AI Coding Assistants (Aider, OpenHands, SWE-agent)
-
-## Research Summary
-
-This report evaluates three open-source AI coding assistants — **Aider**, **OpenHands** (formerly OpenDevin), and **SWE-agent** — against the Dispatch agent harness requirements. Aider is a mature, single-agent CLI tool focused on AI pair programming within git repos. OpenHands is a comprehensive SDK and platform for building coding agents, offering the richest feature set (skills system, security policies, state persistence, provider abstraction). SWE-agent is an academic research tool optimized for autonomous GitHub issue resolution on SWE-bench, now superseded by mini-SWE-agent. None of the three frameworks natively support the three-layer dispatch->orchestrator->subagent hierarchy that Dispatch requires, but OpenHands' SDK architecture provides the most extensible foundation for building such a system.
-
----
-
-## Findings
-
-## 1. Aider
-
-### Core Architecture
-
-Aider is a **single-agent** AI pair programming tool that operates as an interactive CLI. It supports no multi-agent hierarchy — the user talks to one instance, which uses one LLM (or two in architect mode). Its architecture consists of a `Coder` class that manages file editing, a `Model` class for LLM interaction, and an `InputOutput` class for user I/O. Aider runs in the user's terminal connected to their local git repository.
-
-- Language: **Python** (80%), with CSS, Shell, Tree-sitter Query, JavaScript, HTML
-- GitHub: **45k stars**, 4.4k forks, **13,135 commits**, 93 releases (latest v0.86.0, Aug 9, 2025)
-- Primary use case: AI pair programming in the terminal — edit code through natural language conversations
-- Architect mode: Uses a two-model sequential pipeline (architect proposes changes, editor applies them), but this is not a hierarchy — it's a sequential two-step within a single session
-- [Source: Aider GitHub repo](https://github.com/Aider-AI/aider)
-
-### Key Features Identified
-
-- **Provider-agnostic LLM**: Supports OpenAI, Anthropic, Gemini, DeepSeek, OpenRouter, Ollama, local models, and 15+ more through model aliases and `--model` flag
-- **Git integration**: Automatically commits all AI changes with sensible commit messages, supports `/undo`
-- **Repository map**: Builds a compressed map of the codebase for better context in larger projects
-- **Lint/test integration**: Runs linters (per-language configurable via `--lint-cmd`) and tests after edits; automatically fixes detected errors
-- **Chat modes**: `code`, `ask`, `architect`, `help`, `context` modes for different interaction styles
-- **In-chat commands**: 30+ slash commands including `/model`, `/editor-model`, `/add`, `/drop`, `/clear`, `/reset`, `/run`, `/test`, `/save`
-- **IDE integration**: `--watch-files` mode watches for AI-prefixed comments in any editor
-- **Conventions system**: Markdown files loaded as read-only context (e.g., `CONVENTIONS.md`) to guide LLM behavior
-- **Scripting**: Python API via `Coder.create()` + `coder.run()` and CLI with `--message` flag
-- **Model switching mid-conversation**: `/model` and `/editor-model` commands allow switching LLMs mid-session
-
-### Checklist Evaluation
-
-| # | Requirement | Status | Evidence |
-|---|-------------|--------|----------|
-| 1 | **Three-layer hierarchy** | Not supported | Aider is a single-agent system. Architect mode uses 2 models sequentially, not as a hierarchy. No dispatch/orchestrator/subagent layers exist. |
-| 2 | **Config-driven orchestrators** | Not supported | Orchestrators do not exist. Configuration (`.aider.conf.yml`) is for tool-level settings, not agent type definitions. |
-| 3 | **Parallel subagent execution** | Not supported | Single agent, single-threaded. No concept of subagents or parallel execution. |
-| 4 | **Strict hierarchy communication** | Not supported | No hierarchy exists. Communication is user<->aider only. |
-| 5 | **User-to-agent messaging mid-execution** | Partial | The user can interrupt with Ctrl-C and send new messages, but cannot route messages to specific agents in a hierarchy (no hierarchy exists). Messages are delivered immediately to the single active agent. |
-| 6 | **Conflict prevention** | Not supported | Single-agent design — no parallel agents to conflict with. |
-| 7 | **Role-scoped tooling** | Not supported | All agents (single instance) have the same tool set. No role-based tool differentiation. |
-| 8 | **Skills system** | Partial | Conventions files (`CONVENTIONS.md`) provide markdown instructions loaded into context, but there is no formal directory-based organizational system (no `~/.skills/` or `<project>/.skills/` structure). Skills are manual file references. |
-| 9 | **LSP integration** | Partial | Aider has linting via `--lint-cmd` (per-language linters) but this is shell-based linting, not LSP protocol integration. No Language Server Protocol support for real-time diagnostics. [Source: Linting and testing docs](https://aider.chat/docs/usage/lint-test.html) |
-| 10 | **Shell access with directory permissions** | Not supported | `/run` command provides shell access but with no directory-level permission controls. No auto-allow lists or out-of-scope prompts. |
-| 11 | **Session management** | Partial | `/model` and `/editor-model` allow model switching mid-conversation. `/save` saves file list. But no chat forking, no loading/resuming old chats. History is ephemeral per session. |
-| 12 | **Human-in-the-loop checkpoints** | Partial | `--yes` flag auto-accepts all confirmation. `/undo` reverts changes. But no configurable checkpoints (e.g., "pause after planning for approval"). |
-| 13 | **State persistence** | Not supported | Git commits persist code changes. But session state, conversation history, and plans are NOT persisted across restarts. |
-| 14 | **Provider-agnostic LLM** | Full | Supports OpenAI, Anthropic, Gemini, DeepSeek, OpenRouter, Ollama, 15+ others via `--model` flag and model aliases. Abstract model interface. [Source: LLMs docs](https://aider.chat/docs/llms.html) |
-| 15 | **Multiple interfaces** | Partial | CLI only. Can be scripted via Python API and `--message` flag for non-interactive use, but no API server, no TUI (beyond the terminal prompt), no web UI. |
-
-### Key Quotes
-
-> "Aider lets you pair program with LLMs to start a new project or build on your existing codebase." [Source: Aider README](https://github.com/Aider-AI/aider)
-
-> "Aider can connect to almost any LLM, including local models." [Source: Aider README](https://github.com/Aider-AI/aider)
-
----
-
-## 2. OpenHands (formerly OpenDevin)
-
-### Core Architecture
-
-OpenHands has evolved from a monolithic application into a **four-package SDK architecture**. The Software Agent SDK (`openhands.sdk`) is the foundation, providing core agent framework, LLM abstraction, tool system, workspace management, conversation state, skills system, and security analysis. On top of this sit the OpenHands CLI, GUI (React), Cloud, and Enterprise offerings.
-
-The architecture is: User Interface (CLI/GUI/Cloud) -> Conversation -> Agent (reasoning-action loop) -> Tools/LLM. The system is **stateless and event-driven**, with each agent step being atomic and interruptible.
-
-- Language: **Python (62.3%)** + TypeScript (35.9%) for frontend
-- GitHub: **74.1k stars**, 9.4k forks, **6,737 commits**, 102 releases (latest v1.7.0, May 1, 2026)
-- Primary use case: AI-driven development platform for building and running coding agents at scale
-- Four packages: `openhands.sdk` (core), `openhands.tools` (pre-built tools), `openhands.workspace` (Docker/remote exec), `openhands.agent_server` (FastAPI + WebSocket server)
-- Two deployment modes: Local (in-process) and Production (containerized with agent-server)
-- [Source: OpenHands GitHub repo](https://github.com/OpenHands/OpenHands)
-- [Source: SDK Architecture docs](https://docs.openhands.dev/sdk/arch/overview)
-
-### Key Features Identified
-
-- **Skills system**: Sophisticated three-type system — Repository skills (always-active, from `AGENTS.md`), Knowledge skills (keyword-triggered), Task skills (triggered with structured inputs). Supports MCP tool integration and dynamic content via inline shell commands. Skills are markdown files with YAML frontmatter. [Source: Skill docs](https://docs.openhands.dev/sdk/arch/skill)
-- **Provider-agnostic LLM**: Via LiteLLM, supporting 100+ providers. Configurable via environment variables, JSON, or programmatic. Supports both Chat Completions and OpenAI Responses API. [Source: LLM docs](https://docs.openhands.dev/sdk/arch/llm)
-- **Security system**: Pluggable security analyzers (NoOp or LLM-based) with risk levels (LOW/MEDIUM/HIGH/UNKNOWN) and configurable confirmation policies (AlwaysConfirm, NeverConfirm, ConfirmRisky). Actions can be blocked, require confirmation, or auto-execute based on risk. [Source: Security docs](https://docs.openhands.dev/sdk/arch/security)
-- **Custom tools**: Typed Action/Observation/Executor pattern for creating custom tools. Factory functions support shared executors across tools. [Source: Custom Tools docs](https://docs.openhands.dev/sdk/guides/custom-tools)
-- **State persistence**: Auto-save and resume with debounced writes and incremental events. [Source: Conversation docs](https://docs.openhands.dev/sdk/arch/conversation)
-- **Conversation management**: Two conversation types — LocalConversation (in-process) and RemoteConversation (via HTTP/WebSocket). Factory pattern auto-selects based on workspace type.
-- **Event-driven architecture**: Typed event framework with ActionEvent, ObservationEvent, MessageEvent, StateUpdateEvent. Immutable append-only event log.
-- **Context management**: Condenser system for compressing conversation history when token limits are approached.
-- **MCP integration**: Model Context Protocol servers can be spawned and managed through repository skills.
-
-### Checklist Evaluation
-
-| # | Requirement | Status | Evidence |
-|---|-------------|--------|----------|
-| 1 | **Three-layer hierarchy** | Not supported | OpenHands has a single Conversation->Agent->Tool pipeline, not a dispatch->orchestrator->subagent hierarchy. The README mentions "Major tasks that involve multiple agents, like refactors and rewrites" as a use case, but this must be built manually using the SDK — it is not a native framework feature. |
-| 2 | **Config-driven orchestrators** | Partial | Agents are defined programmatically in Python via the SDK (code-driven, not config-driven). However, the config template file (`config.template.toml`) provides some structure. Orchestrators as config-defined entities do not exist. |
-| 3 | **Parallel subagent execution** | Not supported | The SDK executes one agent step at a time per conversation. No native mechanism for spawning parallel subagents. Would need to be built on top of the SDK. |
-| 4 | **Strict hierarchy communication** | Not supported | No hierarchy enforced. The SDK's event system could be used to build one, but it's not a native constraint. |
-| 5 | **User-to-agent messaging mid-execution** | Full | The Conversation system supports `send_message()` at any time. In the GUI, users can inject messages mid-execution. The RemoteConversation uses WebSocket for real-time communication. The agent's step() loop checks for pending messages on each iteration. [Source: Agent architecture](https://docs.openhands.dev/sdk/arch/agent) |
-| 6 | **Conflict prevention** | Not supported | No native mechanism for assigning non-overlapping file scopes to parallel agents. Would need custom implementation. |
-| 7 | **Role-scoped tooling** | Full | Each agent instance is created with its own tool set (`Agent(llm=llm, tools=[...])`). Different agents can have entirely different tools. Factory functions and shared executors support complex tool topologies. [Source: Custom Tools docs](https://docs.openhands.dev/sdk/guides/custom-tools) |
-| 8 | **Skills system** | Full | Rich skills system with three skill types (Repository/Knowledge/Task), YAML frontmatter in markdown files, keyword triggers, dynamic content execution, MCP integration. Supports `AGENTS.md`, `.cursorrules`, and `.agents/skills/*.md` formats. However, the directory structure (`~/.skills/`, `<project>/.skills/`) differs from the Dispatch spec — OpenHands uses `AGENTS.md` at repo root and `.agents/skills/` directory. [Source: Skill docs](https://docs.openhands.dev/sdk/arch/skill) |
-| 9 | **LSP integration** | Not supported | No LSP integration found in the SDK documentation. The tools package includes `FileEditorTool` and `BashTool` but no LSP-based diagnostic tools. |
-| 10 | **Shell access with directory permissions** | Partial | Full shell access via `BashTool` and `TerminalTool`. SecurityAnalyzer provides risk-based action validation (LOW/MEDIUM/HIGH risk levels with configurable confirmation thresholds). However, this is a general action risk system, NOT a directory-based permission system (no auto-allow lists for specific paths, no prompting for out-of-scope directories). [Source: Security docs](https://docs.openhands.dev/sdk/arch/security) |
-| 11 | **Session management** | Partial | State persistence with auto-save and resume exists. Model switching is configurable at agent creation but there is no `/model` command for mid-conversation switching. No chat forking feature documented. |
-| 12 | **Human-in-the-loop checkpoints** | Full | ConfirmationPolicy supports AlwaysConfirm, NeverConfirm, and ConfirmRisky modes. The Agent step() loop checks for pending confirmations before executing actions. SecurityAnalyzer provides risk assessment for each action. Configurable thresholds. [Source: Agent architecture](https://docs.openhands.dev/sdk/arch/agent) |
-| 13 | **State persistence** | Full | Persistence service with auto-save and resume. Debounced writes, incremental events. Conversation state, event history, and workspace state are persisted. [Source: Conversation docs](https://docs.openhands.dev/sdk/arch/conversation) |
-| 14 | **Provider-agnostic LLM** | Full | Via LiteLLM backend supporting 100+ providers. LLM class with environment variable, JSON, and programmatic configuration. Dual API support (Chat Completions + Responses API). Telemetry and cost tracking. [Source: LLM docs](https://docs.openhands.dev/sdk/arch/llm) |
-| 15 | **Multiple interfaces** | Full | CLI (OpenHands CLI), GUI (React single-page app), Cloud (hosted deployment), Enterprise (self-hosted VPC), SDK (Python + REST API via agent-server). WebSocket support for real-time communication. [Source: OpenHands README](https://github.com/OpenHands/OpenHands) |
-
-### Key Quotes
-
-> "The SDK is a composable Python library that contains all of our agentic tech. It's the engine that powers everything else below." [Source: OpenHands README](https://github.com/OpenHands/OpenHands)
-
-> "You can use the OpenHands Software Agent SDK for: ... Major tasks that involve multiple agents, like refactors and rewrites." [Source: SDK docs](https://docs.openhands.dev/sdk)
-
-> "The Software Agent SDK serves as the source of truth for agents in OpenHands." [Source: SDK Architecture docs](https://docs.openhands.dev/sdk/arch/overview)
-
----
-
-## 3. SWE-agent
-
-### Core Architecture
-
-SWE-agent is a **single-agent** system designed for autonomous resolution of GitHub issues. Its architecture: `sweagent` CLI -> `Agent` (reasoning loop with LLM) + `SWEEnv` (environment manager wrapping SWE-ReX). SWE-ReX manages a Docker container running a shell session with custom tool implementations. The agent prompts an LLM, the LLM outputs tool calls, which are parsed and executed in the Docker sandbox.
-
-The tool system is organized as "tool bundles" — directories containing executable scripts, a `config.yaml`, and an `install.sh`. Tools are configured via YAML config files.
-
-- Language: **Python (94.8%)**
-- GitHub: **19.2k stars**, 2.1k forks, **2,158 commits**, 10 releases (latest v1.1.0, May 22, 2025)
-- Primary use case: Academic research benchmark for autonomous SWE-bench issue resolution
-- **Superseded**: The project now recommends mini-SWE-agent (65% on SWE-bench verified in 100 lines of Python)
-- [Source: SWE-agent GitHub repo](https://github.com/SWE-agent/SWE-agent)
-- [Source: Architecture docs](https://swe-agent.com/latest/background/architecture/)
-
-### Key Features Identified
-
-- **YAML-driven configuration**: Single config file defines tools, prompts, history processors, model settings, environment. Multiple config files can be merged with `--config`.
-- **Tool bundles**: Extensible tool system. Tools are executables in a `bin/` directory with a `config.yaml` defining signatures, arguments, and docstrings. State commands return JSON after each action.
-- **History processors**: Plugable context compression, including `cache_control` (last N messages) and `image_parsing` for multimodal support.
-- **Batch mode**: `sweagent run-batch` with `--num_workers` for parallel execution across multiple instances. Supports SWE-bench, HuggingFace, file-based, and expert instance sources.
-- **Multimodal support**: Config for processing images from GitHub issues, extended observation lengths, web browsing tools.
-- **Demonstrations**: Pre-recorded trajectories can guide agent behavior.
-- **Template system**: Three template types — system_template (initial prompt), instance_template (per-task context), next_step_template (per-turn prompt). Templates use Jinja-like syntax.
-- **SWE-ReX integration**: Separate package for managing remote execution environments (Docker, Modal, AWS).
-
-### Checklist Evaluation
-
-| # | Requirement | Status | Evidence |
-|---|-------------|--------|----------|
-| 1 | **Three-layer hierarchy** | Not supported | Single agent resolving one issue at a time. No dispatch/orchestrator/subagent layers. |
-| 2 | **Config-driven orchestrators** | Not supported | SWE-agent is configured via YAML but configures a single agent type. Orchestrators as dispatch-defined entities do not exist. |
-| 3 | **Parallel subagent execution** | Partial | Batch mode (`sweagent run-batch --num_workers N`) runs multiple independent agent instances in parallel on separate issues. This is horizontal parallelism of independent tasks, NOT hierarchical subagent parallelism. [Source: Batch mode docs](https://swe-agent.com/latest/usage/batch_mode/) |
-| 4 | **Strict hierarchy communication** | Not supported | No hierarchy exists. Each agent operates independently in its own Docker container. |
-| 5 | **User-to-agent messaging mid-execution** | Not supported | SWE-agent is designed for autonomous execution. No mechanism for injecting user messages to a running agent mid-task. |
-| 6 | **Conflict prevention** | Not supported | Single agent per task. Batch mode tasks are independent (different GitHub issues), so no file conflicts. |
-| 7 | **Role-scoped tooling** | Not supported | Single agent type per config file. All agents in a batch use the same tool set. |
-| 8 | **Skills system** | Partial | Template system supports system prompts, instance prompts, and next-step prompts defined in YAML config. Demonstrations provide trajectory-based guidance. However, there is NO directory-based markdown skills system with auto-loading (no `~/.skills/` or `<project>/.skills/`). [Source: Templates docs](https://swe-agent.com/latest/config/templates/) |
-| 9 | **LSP integration** | Not supported | No LSP integration found. Tools are shell-based (file viewer, editor, bash). No compiler diagnostic integration. |
-| 10 | **Shell access with directory permissions** | Not supported | Full shell access inside Docker container via bash tool. No directory-level permission system. Docker provides container-level isolation but not fine-grained directory permissions. |
-| 11 | **Session management** | Not supported | No chat forking, no model switching mid-conversation, no loading/resuming old conversations. Trajectories are saved as output files for post-hoc analysis. |
-| 12 | **Human-in-the-loop checkpoints** | Not supported | Designed for fully autonomous execution. No configurable checkpoints for user approval. |
-| 13 | **State persistence** | Partial | Trajectories and predictions are saved to files (`preds.json`, trajectory files). But no formal state persistence for resuming interrupted sessions. The `sweagent merge-preds` utility can recover partial batch results. |
-| 14 | **Provider-agnostic LLM** | Full | Model configured in YAML (`agent.model.name`). Supports any LM via config. Model config includes per-instance cost limits, temperature, and other parameters. [Source: Config docs](https://swe-agent.com/latest/config/config/) |
-| 15 | **Multiple interfaces** | Partial | CLI only (`sweagent run`, `sweagent run-batch`, `sweagent merge-preds`). No API, no TUI, no web UI. |
-
-### Key Quotes
-
-> "SWE-agent takes a GitHub issue and tries to automatically fix it, using your LM of choice." [Source: SWE-agent README](https://github.com/SWE-agent/SWE-agent)
-
-> "Most of our current development effort is on mini-swe-agent, which has superseded SWE-agent." [Source: SWE-agent README](https://github.com/SWE-agent/SWE-agent)
-
-> "Configurable & fully documented: Governed by a single yaml file." [Source: SWE-agent README](https://github.com/SWE-agent/SWE-agent)
-
----
-
-## Key Questions
-
-### 1. What is each framework's core architecture? How many layers of agent hierarchy does it support?
-
-- **Aider**: Single-layer (User <-> Agent). No hierarchy. The "architect mode" uses two models sequentially but is still a single-agent session.
-- **OpenHands**: Single-layer (Conversation -> Agent -> Tools). The SDK can be used to build multi-agent systems programmatically, but no hierarchy is natively enforced. The four-package architecture provides building blocks.
-- **SWE-agent**: Single-layer (CLI -> Agent + Environment). One agent per instance. Batch mode runs independent agents in parallel but no hierarchy.
-
-### 2. How extensible/configurable is each framework without modifying source code?
-
-- **Aider**: Modular via command-line flags, YAML config file, environment variables, and `.env` files. Can be scripted via Python API. Adding new LLM providers requires no code changes (model aliases). No plugin system for tools or new behaviors.
-- **OpenHands**: Highly extensible via the SDK. Custom tools (typed Action/Observation/Executor pattern), custom agents, custom security analyzers, MCP server integration, workspace implementations. Requires Python code for configuration but has clean extension points. Config template file provides some non-code configuration.
-- **SWE-agent**: Extensible via YAML config files (tools, prompts, models, environments, history processors). Custom tools are executables in tool bundles with a config YAML. Adding tools requires creating executable scripts but no modification of core source code. Now in maintenance-only mode.
-
-### 3. What is the primary use case each framework was designed for?
-
-- **Aider**: Interactive AI pair programming — a developer chatting with an LLM to edit code in a git repository.
-- **OpenHands**: General-purpose AI-driven development platform — from simple one-off tasks to complex multi-agent workflows, with CLI, GUI, Cloud, and Enterprise deployments.
-- **SWE-agent**: Academic research benchmark for autonomous SWE-bench issue resolution. Designed to evaluate LLMs on real-world GitHub issues.
-
-### 4. How active is each project?
-
-| Metric | Aider | OpenHands | SWE-agent |
-|--------|-------|-----------|-----------|
-| GitHub Stars | 45k | 74.1k | 19.2k |
-| Forks | 4.4k | 9.4k | 2.1k |
-| Commits | 13,135 | 6,737 | 2,158 |
-| Releases | 93 | 102 | 10 |
-| Latest Release | v0.86.0 (Aug 2025) | v1.7.0 (May 2026) | v1.1.0 (May 2025) |
-| Status | **Active development** | **Active development** | **Maintenance-only** (superseded by mini-SWE-agent) |
-
-### 5. What language is each framework written in?
-
-- **Aider**: Python (80%), CSS, Shell, Tree-sitter Query, JavaScript, HTML
-- **OpenHands**: Python (62.3%), TypeScript (35.9%), Go Template, Jinja, Makefile, CSS
-- **SWE-agent**: Python (94.8%), JavaScript, CSS, Shell, C++, Perl
-
----
-
-## Summary Comparison Table
-
-| # | Requirement | Aider | OpenHands | SWE-agent |
-|---|-------------|-------|-----------|-----------|
-| 1 | Three-layer hierarchy | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 2 | Config-driven orchestrators | ❌ Not supported | ⚠️ Partial (SDK is code-driven) | ❌ Not supported |
-| 3 | Parallel subagent execution | ❌ Not supported | ❌ Not supported | ⚠️ Partial (batch mode parallelism) |
-| 4 | Strict hierarchy communication | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 5 | User-to-agent messaging mid-execution | ⚠️ Partial (single agent) | ✅ Full (WebSocket, send_message) | ❌ Not supported |
-| 6 | Conflict prevention | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 7 | Role-scoped tooling | ❌ Not supported | ✅ Full (per-agent tool sets) | ❌ Not supported |
-| 8 | Skills system | ⚠️ Partial (conventions files) | ✅ Full (3 skill types, triggers, MCP) | ⚠️ Partial (templates + demonstrations) |
-| 9 | LSP integration | ⚠️ Partial (shell linting) | ❌ Not supported | ❌ Not supported |
-| 10 | Shell access w/ directory permissions | ❌ Not supported | ⚠️ Partial (risk-based security) | ❌ Not supported |
-| 11 | Session management | ⚠️ Partial (/model, /save) | ⚠️ Partial (persistence, no forking) | ❌ Not supported |
-| 12 | Human-in-the-loop checkpoints | ⚠️ Partial (--yes, /undo) | ✅ Full (ConfirmationPolicy) | ❌ Not supported |
-| 13 | State persistence | ❌ Not supported | ✅ Full (auto-save & resume) | ⚠️ Partial (trajectory files) |
-| 14 | Provider-agnostic LLM | ✅ Full (15+ providers) | ✅ Full (100+ via LiteLLM) | ✅ Full (model config in YAML) |
-| 15 | Multiple interfaces | ⚠️ Partial (CLI + Python API) | ✅ Full (CLI, GUI, Cloud, API) | ⚠️ Partial (CLI only) |
-
-**Scoring:**
-- **Aider**: 0 Full / 5 Partial / 10 Not supported
-- **OpenHands**: 7 Full / 4 Partial / 4 Not supported
-- **SWE-agent**: 1 Full / 4 Partial / 10 Not supported
-
----
-
-## Source List
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [Aider GitHub Repository](https://github.com/Aider-AI/aider) | GitHub |
-| 2 | [Aider Configuration Docs](https://aider.chat/docs/config.html) | Official docs |
-| 3 | [Aider Chat Modes Docs](https://aider.chat/docs/usage/modes.html) | Official docs |
-| 4 | [Aider In-Chat Commands](https://aider.chat/docs/usage/commands.html) | Official docs |
-| 5 | [Aider Linting and Testing](https://aider.chat/docs/usage/lint-test.html) | Official docs |
-| 6 | [Aider IDE Integration](https://aider.chat/docs/usage/watch.html) | Official docs |
-| 7 | [Aider Scripting API](https://aider.chat/docs/scripting.html) | Official docs |
-| 8 | [Aider Coding Conventions](https://aider.chat/docs/usage/conventions.html) | Official docs |
-| 9 | [OpenHands GitHub Repository](https://github.com/OpenHands/OpenHands) | GitHub |
-| 10 | [OpenHands SDK Overview](https://docs.openhands.dev/sdk) | Official docs |
-| 11 | [OpenHands SDK Architecture](https://docs.openhands.dev/sdk/arch/overview) | Official docs |
-| 12 | [OpenHands Agent Architecture](https://docs.openhands.dev/sdk/arch/agent) | Official docs |
-| 13 | [OpenHands Conversation Architecture](https://docs.openhands.dev/sdk/arch/conversation) | Official docs |
-| 14 | [OpenHands LLM Architecture](https://docs.openhands.dev/sdk/arch/llm) | Official docs |
-| 15 | [OpenHands Skill System](https://docs.openhands.dev/sdk/arch/skill) | Official docs |
-| 16 | [OpenHands Security System](https://docs.openhands.dev/sdk/arch/security) | Official docs |
-| 17 | [OpenHands Custom Tools Guide](https://docs.openhands.dev/sdk/guides/custom-tools) | Official docs |
-| 18 | [OpenHands Hello World](https://docs.openhands.dev/sdk/guides/hello-world) | Official docs |
-| 19 | [SWE-agent GitHub Repository](https://github.com/SWE-agent/SWE-agent) | GitHub |
-| 20 | [SWE-agent Architecture](https://swe-agent.com/latest/background/architecture/) | Official docs |
-| 21 | [SWE-agent Config Files](https://swe-agent.com/latest/config/config/) | Official docs |
-| 22 | [SWE-agent Templates](https://swe-agent.com/latest/config/templates/) | Official docs |
-| 23 | [SWE-agent Tools](https://swe-agent.com/latest/config/tools/) | Official docs |
-| 24 | [SWE-agent Batch Mode](https://swe-agent.com/latest/usage/batch_mode/) | Official docs |
-
----
-
-## Verbatim Quotes
-
-- "aider is AI pair programming in your terminal" — [Source: Aider README](https://github.com/Aider-AI/aider)
-- "Aider can connect to almost any LLM, including local models." — [Source: Aider README](https://github.com/Aider-AI/aider)
-- "The SDK is a composable Python library that contains all of our agentic tech. It's the engine that powers everything else below." — [Source: OpenHands README](https://github.com/OpenHands/OpenHands)
-- "The Software Agent SDK serves as the source of truth for agents in OpenHands." — [Source: SDK Architecture docs](https://docs.openhands.dev/sdk/arch/overview)
-- "You can use the OpenHands Software Agent SDK for: ... Major tasks that involve multiple agents, like refactors and rewrites." — [Source: SDK docs](https://docs.openhands.dev/sdk)
-- "SWE-agent takes a GitHub issue and tries to automatically fix it, using your LM of choice." — [Source: SWE-agent README](https://github.com/SWE-agent/SWE-agent)
-- "Most of our current development effort is on mini-swe-agent, which has superseded SWE-agent." — [Source: SWE-agent README](https://github.com/SWE-agent/SWE-agent)
-- "Configurable & fully documented: Governed by a single yaml file." — [Source: SWE-agent README](https://github.com/SWE-agent/SWE-agent)
-- "OpenHands is also the leading open source framework for coding agents. It's MIT-licensed, and can work with any LLM." — [Source: SDK docs](https://docs.openhands.dev/sdk)
-- "The agent operates through a single-step execution model where each step() call processes one reasoning cycle." — [Source: Agent Architecture](https://docs.openhands.dev/sdk/arch/agent)
-
----
-
-## Source Quality Flags
-
-- Source 5, 6, 7, 8 (Aider docs): Official documentation — high quality, maintained by project maintainers.
-- Source 10-18 (OpenHands docs): Official SDK documentation — comprehensive, well-structured, with architecture diagrams and runnable code examples.
-- Source 20-24 (SWE-agent docs): Official documentation — includes architecture diagrams and configuration references. However, the project is now in maintenance-only mode with recommendation to use mini-SWE-agent.
-
----
-
-## Confidence: High
-
-All information was gathered from primary sources (GitHub repositories, official documentation sites). No AI-generated summaries or marketing materials were used. The frameworks' architectures and capabilities are well-documented and verifiable.
-
----
-
-## Gaps and Open Questions
-
-1. **Multi-agent hierarchy in OpenHands**: The OpenHands SDK README mentions "Major tasks that involve multiple agents" as a use case, but the public SDK documentation does not yet show concrete multi-agent orchestration examples. The multi-agent example directory (`examples/02_multi_agent_hello_world/`) was attempted but returned a 404, suggesting it may not exist yet. A follow-up investigation should check whether multi-agent orchestration patterns exist in the SDK source code.
-
-2. **OpenHands SDK vs Application distinction**: The OpenHands ecosystem has been restructured. The monolithic GitHub repo (`OpenHands/OpenHands`) now points to the SDK, CLI, and GUI as separate packages. The relationship between the legacy OpenDevin application repo and the new SDK was not fully explored and could affect feature availability.
-
-3. **SWE-agent mini-SWE-agent relationship**: SWE-agent now recommends mini-SWE-agent, which achieves comparable performance in "100 lines of Python." This research covered SWE-agent 1.0, but mini-SWE-agent may have a substantially different architecture worth evaluating separately.
-
-4. **None of the three frameworks natively support the three-layer hierarchy Dispatch requires.** The closest foundation is OpenHands' SDK, which provides the building blocks (per-agent tool scoping, event system, security policies, skills system, state persistence) but lacks the hierarchical orchestration layer. Building Dispatch on top of OpenHands SDK would require implementing the orchestrator and dispatcher layers as custom Python code.
-
----
-
-## Tool Calls Made
-
-1. `webfetch` https://github.com/Aider-AI/aider
-2. `webfetch` https://github.com/All-Hands-AI/OpenHands
-3. `webfetch` https://github.com/princeton-nlp/SWE-agent
-4. `webfetch` https://aider.chat/docs/config.html
-5. `webfetch` https://aider.chat/docs/usage.html
-6. `webfetch` https://swe-agent.com/latest/
-7. `webfetch` https://docs.openhands.dev/sdk
-8. `webfetch` https://swe-agent.com/latest/background/architecture/
-9. `webfetch` https://docs.openhands.dev/sdk/arch/overview
-10. `webfetch` https://aider.chat/docs/usage/modes.html
-11. `webfetch` https://swe-agent.com/latest/config/config/
-12. `webfetch` https://docs.openhands.dev/sdk/arch/agent
-13. `webfetch` https://docs.openhands.dev/sdk/arch/skill
-14. `webfetch` https://aider.chat/docs/scripting.html
-15. `webfetch` https://swe-agent.com/latest/usage/batch_mode/
-16. `webfetch` https://aider.chat/docs/usage/lint-test.html
-17. `webfetch` https://aider.chat/docs/usage/watch.html
-18. `webfetch` https://docs.openhands.dev/sdk/arch/conversation
-19. `webfetch` https://docs.openhands.dev/sdk/arch/llm
-20. `webfetch` https://aider.chat/docs/usage/conventions.html
-21. `webfetch` https://docs.openhands.dev/sdk/arch/security
-22. `webfetch` https://swe-agent.com/latest/config/tools/
-23. `webfetch` https://aider.chat/docs/usage/commands.html
-24. `webfetch` https://docs.openhands.dev/sdk/guides/hello-world
-25. `webfetch` https://swe-agent.com/latest/config/templates/
-26. `webfetch` https://raw.githubusercontent.com/OpenHands/software-agent-sdk/main/examples/02_multi_agent_hello_world/01_multi_agent_basic.py
-27. `webfetch` https://docs.openhands.dev/sdk/guides/custom-tools
-28. `webfetch` https://github.com/SWE-agent/SWE-agent?tab=readme-ov-file
diff --git a/research/ai-coding-assistants-newer.md b/research/ai-coding-assistants-newer.md
deleted file mode 100644
index 932e244..0000000
--- a/research/ai-coding-assistants-newer.md
+++ /dev/null
@@ -1,451 +0,0 @@
-# Subagent Report: AI Coding Assistants — Goose, Mentat, Cline, Continue
-
-## Research summary
-
-This report evaluates four open-source AI coding assistants — Goose (Block/AAIF), Mentat (AbanteAI, archived), Cline, and Continue — against a 15-point requirements checklist focused on agent hierarchy, configurability, parallelism, security, and integration capabilities. Goose and Cline are the most full-featured frameworks, with multi-agent support, provider-agnostic LLM interfaces, plugin/skills systems, and human-in-the-loop controls. Continue has pivoted from an IDE assistant to a CI-focused "AI checks" product. Mentat (the original AbanteAI CLI tool) has been archived since January 2025 and is no longer maintained. Confidence is high for Goose, Cline, and Continue based on current docs and repos; Mentat information reflects its archived state.
-
----
-
-## Findings
-
-### 1. Goose (by Block / AAIF)
-
-**GitHub**: [github.com/aaif-goose/goose](https://github.com/aaif-goose/goose) — 45.5k stars, 4,541 commits
-**Language**: Rust (49.8%) + TypeScript (44.6%)
-**Latest release**: v1.34.1 (May 15, 2026)
-**License**: Apache 2.0
-**Governance**: Now under Agentic AI Foundation (AAIF) at Linux Foundation
-
-**Core Architecture**: Goose has a three-component architecture: (1) **Interface** (desktop app, CLI, API), (2) **Agent** (core loop managing LLM interaction), and (3) **Extensions** (MCP-based tools). It supports spawning **subagents** — independent agent instances that execute tasks with process isolation, running sequentially or in parallel. Subagents inherit extensions from the parent but can be restricted.
-
-Key features:
-- Desktop app + CLI + API modes
-- 15+ LLM providers via abstract interface (Anthropic, OpenAI, Google, Ollama, OpenRouter, Azure, Bedrock, etc.)
-- MCP (Model Context Protocol) extension system with 70+ extensions
-- ACP (Agent Client Protocol) support for interoperability
-- Subagent system with parallel/sequential execution, recipe-based reusable configs, and external subagents (Codex, Claude Code)
-- Skills system: `~/.agents/skills/` and `.agents/skills/` directories with `SKILL.md` files in named subdirectories; also compatible with `.claude/skills/`
-- `.goosehints` files for project context (global at `~/.config/goose/.goosehints`, local per-directory, hierarchical)
-- Config-driven via YAML config files (`config.yaml`, `permission.yaml`, `secrets.yaml`)
-- Session management: start, resume, search sessions; smart context management with auto-compaction
-- Permission modes: auto, approve, chat, smart_approve
-- Prompt injection detection, adversary mode, sandboxing for desktop app
-- Extension allowlist for access control
-
-**Primary Use Case**: General-purpose AI agent for code, research, writing, automation, data analysis.
-
-**Activity**: Very active — 4,541 commits, 134 releases, moved to AAIF in April 2026.
-
----
-
-### 2. Mentat (by AbanteAI)
-
-**Original GitHub**: [github.com/AbanteAI/archive-old-cli-mentat](https://github.com/AbanteAI/archive-old-cli-mentat) — 2.6k stars (archived)
-**Language**: Python (87.9%) + TypeScript (8.4%)
-**Status**: **Archived January 7, 2025** — no longer maintained or supported
-
-The original Mentat was a command-line AI coding assistant with the following characteristics:
-
-- Single-agent CLI tool (no multi-agent hierarchy)
-- Used GPT-4 models via OpenAI API (later supported alternatives via litellm proxy)
-- Worked within git repositories, editing multiple files
-- Had a VS Code extension (`mentat-vscode/`)
-- Supported auto-context via RAG (retrieval-augmented generation) using universal-ctags
-- File exclusion via glob patterns
-- Configuration via config files
-- Used Python SDK's OpenAI client under the hood
-
-**Key limitations for requirements checklist**:
-- No multi-agent hierarchy (single agent, no orchestrator/subagent concept)
-- No config-driven orchestrators
-- No parallel execution
-- No hierarchical communication restrictions
-- No user-to-agent mid-execution messaging
-- No conflict prevention for parallel agents
-- No role-scoped tooling
-- No skills system (basic context via auto-context only)
-- No LSP integration
-- Shell access without directory permissions
-- Basic session management (no forking, model switching mid-conversation)
-- No human-in-the-loop checkpoints
-- State persistence limited to git context
-- Provider-agnostic via litellm proxy workaround, not native abstract interface
-- CLI only (no TUI, no API mode documented)
-
-**Note**: The name "Mentat" is now used by a different project (an AI-powered GitHub bot at mentat.ai), but this report evaluates the original AbanteAI Mentat per the task scope.
-
-**Activity**: Archived project with 560 commits, last release v1.0.18 (Apr 2024), last PyPI release v1.0.19 (Jan 2025 — marked as archived).
-
----
-
-### 3. Cline (formerly Claude Dev)
-
-**GitHub**: [github.com/cline/cline](https://github.com/cline/cline) — 62k stars, 5,919 commits
-**Language**: TypeScript (97.7%)
-**Latest CLI release**: v3.0.7 (May 18, 2026)
-**License**: Apache 2.0
-**Organization**: Cline Bot Inc.
-
-**Core Architecture**: Cline is built on a layered SDK architecture:
-- `@cline/sdk` — Public SDK surface
-- `@cline/core` — Node runtime for sessions, built-in tools, persistence, hub support, automation
-- `@cline/agents` — Browser-compatible stateless agent execution loop
-- `@cline/llms` — Provider gateway and model catalogs
-- `@cline/shared` — Types, schemas, tool helpers
-
-Cline operates in multiple form factors: **CLI** (terminal with interactive or headless modes), **VS Code Extension**, **JetBrains Plugin**, and **Kanban** (web-based multi-agent task board).
-
-Key features:
-- **Multi-agent teams**: Coordinator agent breaks work into subtasks and delegates to specialist agents, each with their own tools and context. Team state persists across sessions.
-- **Kanban**: Run many agents in parallel from a web-based task board; each card gets its own worktree and auto-commit
-- **Rules system**: `.clinerules` files for project-specific guidance
-- **Skills system**: `.agents/skills/` directory with SKILL.md files (compatible with Goose/Claude skill format)
-- **Plugin system**: `AgentPlugin` with hooks into lifecycle stages (beforeRun, afterRun, beforeTool, afterTool, etc.)
-- **MCP support**: Load MCP settings through runtime/config extension path
-- **Provider-agnostic**: 200+ models via OpenRouter, plus Anthropic, OpenAI, Google, AWS Bedrock, Azure, Ollama, LM Studio, and any OpenAI-compatible API
-- **Tool policies**: Per-tool auto-approve/require-approval/disable
-- **Checkpoints and state persistence**: Sessions persist across restarts; snapshot/restore capability
-- **CLI commands**: `cline auth`, `cline config`, `cline mcp`, `cline history`, `cline schedule`, `cline hub`, `cline kanban`
-- **Scheduled agents**: Cron-based recurring automations
-- **Headless mode**: For CI/CD with JSON output and auto-approve
-- **Human-in-the-loop**: Approval required per action (configurable); Plan mode vs Act mode
-- **Chat integrations**: Slack, Telegram, Discord, Google Chat, WhatsApp, Linear
-- **Enterprise features**: SSO, RBAC, observability (OpenTelemetry, Datadog, Grafana)
-
-**Primary Use Case**: AI coding agent in IDE and terminal; also used as SDK for building custom agent applications.
-
-**Activity**: Very active — 5,919 commits, 260 releases, 62k stars, 3.3k dependents.
-
----
-
-### 4. Continue (Continue Dev, Inc.)
-
-**GitHub**: [github.com/continuedev/continue](https://github.com/continuedev/continue) — 33.3k stars, 21,498 commits
-**Language**: TypeScript (84.4%) + Kotlin (3.8%) + Python (2.2%)
-**Latest release**: v1.2.22-vscode (Mar 27, 2026)
-**License**: Apache 2.0
-
-**Note**: Continue has undergone a significant pivot. The project originally was an open-source IDE code assistant (VS Code + JetBrains) with chat, edit, autocomplete, and agent modes. It has now pivoted to **Source-controlled AI checks, enforceable in CI** — a CI-focused product where checks are defined as markdown files in `.continue/checks/` and run as GitHub status checks on every PR.
-
-**Current Architecture** (post-pivot):
-- Checks defined as markdown files with YAML frontmatter in `.continue/checks/` or `.agents/checks/`
-- Each check file has `name`, `description`, optional `model`, and a markdown body prompt
-- CLI (`cn`) runs checks locally and in CI
-- GitHub integration for PR status checks
-- "Agents" for event-triggered automation (schedules, new issues, webhooks)
-- Cloud agent gallery with pre-configured agents
-
-**Legacy IDE extension** (still available):
-- VS Code Agent, Chat, Edit, Autocomplete modes
-- JetBrains plugin
-- MCP support
-- Multiple LLM provider support (via config)
-
-**Primary Use Case (current)**: AI-powered code review checks in CI/CD pipelines.
-
-**Activity**: Active — 21,498 commits (the highest commit count), 822 releases. However, recent focus appears to be on the CI/checks product rather than the agent framework.
-
----
-
-## Requirements Checklist
-
-### Requirement 1: Three-layer hierarchy (dispatch -> orchestrator -> subagent)
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Partial** | Supports subagents (spawned by main agent) but no formal three-layer hierarchy. Has main agent → subagent (1 level deep). Subagents cannot spawn further subagents. Recipes enable reusable subagent configs. External subagents (Codex, Claude Code) supported via MCP. |
-| **Mentat** | **Not at all** | Single-agent CLI tool. No notion of orchestrator or subagents. |
-| **Cline** | **Partial** | Supports multi-agent teams: coordinator agent delegates to specialist agents. CLI supports `--team-name` flag. Kanban enables parallel agent execution. SDK enables building custom multi-agent systems. However, no formal three-layer dispatch→orchestrator→subagent hierarchy documented. |
-| **Continue** | **Not at all** | Current product is single-agent checks per PR. No multi-agent hierarchy. Each check runs independently. |
-
-### Requirement 2: Config-driven orchestrators
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Recipes (YAML files) define subagent behavior: instructions, extensions, parameters, prompts. `GOOSE_RECIPE_PATH` env var configures recipe locations. Subagent settings configurable via natural language, env vars, or recipes. |
-| **Mentat** | **Not at all** | No orchestrator concept. Basic config file for model settings. |
-| **Cline** | **Partial** | Orchestrator behavior is primarily code-driven via SDK (TypeScript). Plugin system allows packaging configuration. CLI flags (`--team-name`, `--plan`, `--auto-approve`) provide some config-driven behavior. No dedicated YAML orchestration config. |
-| **Continue** | **Not at all** | No orchestrator concept. Check files are markdown with YAML frontmatter, but these are individual check configs, not orchestrator definitions. |
-
-### Requirement 3: Parallel subagent execution
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Subagents can run in parallel using trigger keywords ("parallel", "simultaneously", "concurrently", "at the same time"). Parallel subagents supported natively. |
-| **Mentat** | **Not at all** | Single-threaded CLI, no parallel execution. |
-| **Cline** | **Yes** | Kanban enables parallel agent execution from a web-based task board. SDK multi-agent example demonstrates parallel agents streaming to web UI. Multi-agent teams with coordinator delegating work. |
-| **Continue** | **Not at all** | Checks run sequentially as part of CI pipeline. No parallel subagent execution. |
-
-### Requirement 4: Strict hierarchy communication
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Subagents have restricted operations: cannot spawn further subagents (prevents infinite recursion), cannot manage extensions, cannot manage schedules. Communication flows through the parent agent. |
-| **Mentat** | **Not at all** | No hierarchy exists. |
-| **Cline** | **Partial** | Multi-agent team communication pattern: coordinator → specialist agents. SDK plugin hooks enable lifecycle-based communication control. No explicit peer-to-peer blocking documented but the architecture implies parent-mediated communication. |
-| **Continue** | **Not at all** | No hierarchy exists. |
-
-### Requirement 5: User-to-agent messaging mid-execution
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Sessions are continuous conversations. Users can interrupt and provide input during execution. In-session actions allow sharing information mid-session. Subagent activity is visible in real-time. |
-| **Mentat** | **Partial** | Interactive CLI sessions allow text input during conversation, but no structured mid-execution message injection. |
-| **Cline** | **Yes** | Interactive CLI mode and VS Code extension support continuous chat. Users can interrupt agent execution. `ask_question` tool allows agent to request user input mid-execution. Plan/Act mode switching. |
-| **Continue** | **Not at all** | Checks run autonomously in CI. No user interaction mid-execution. The legacy VS Code extension supports chat but the current CI product does not. |
-
-### Requirement 6: Conflict prevention (non-overlapping file scopes)
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Partial** | Subagents operate with process isolation. Sandbox for desktop app controls file access. No explicit file-scoping mechanism for parallel subagents documented. |
-| **Mentat** | **Not at all** | No parallel execution, no conflict prevention needed. |
-| **Cline** | **Partial** | Kanban gives each agent its own worktree (Git worktree) with auto-commit. This provides file-level isolation. No explicit lock-based conflict prevention. |
-| **Continue** | **Not at all** | No parallel execution. |
-
-### Requirement 7: Role-scoped tooling
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Subagents can be given specific extension sets (e.g., "Create a subagent with only the developer extension"). Extension control via recipes and natural language prompts. `available_tools` field in extension config filters tools. |
-| **Mentat** | **Not at all** | No role-based tool differentiation. |
-| **Cline** | **Yes** | Plugin system allows registering tools per agent. Multi-agent teams: specialist agents get their own tools and context. Tool policies per agent (`autoApprove`, `enabled`). SDK enables fine-grained tool assignment. |
-| **Continue** | **Not at all** | Each check gets the same toolset (PR diff reading). No role-scoped tooling. |
-
-### Requirement 8: Skills system (injectable markdown/text instructions per agent type)
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Skills stored in `~/.agents/skills/<name>/SKILL.md` (global) or `.agents/skills/<name>/SKILL.md` (project-level). YAML frontmatter with `name` and `description`. Supports supporting files (scripts, templates). Also compatible with `.claude/skills/`. Skill Marketplace available. |
-| **Mentat** | **Not at all** | No skills system. Auto-context uses RAG to find relevant code snippets. |
-| **Cline** | **Yes** | Skills in `.agents/skills/` directory with SKILL.md files (same format as Goose). `.clinerules` files for project rules. Plugin system allows bundling rules. Skills system mentioned in built-in tools list. |
-| **Continue** | **Not at all** | Check files are similar to skills in format (markdown + frontmatter) but serve a different purpose (CI review prompts, not injectable agent instructions). No skills directory concept. |
-
-### Requirement 9: LSP integration
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Not at all** | No LSP integration documented. Uses shell commands for compilation checks. |
-| **Mentat** | **Not at all** | No LSP integration. |
-| **Cline** | **Yes** | VS Code extension integrates with editor LSP for compiler diagnostics. Monitors linter and compiler errors as it works. SDK plugin examples include TypeScript LSP tools. Clinerules can reference LSP diagnostics. |
-| **Continue** | **Partial** | Legacy VS Code extension integrates with editor LSP. Current CI-focused product does not use LSP (analyzes PR diffs, not live diagnostics). |
-
-### Requirement 10: Shell access with directory permissions
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Extension allowlist for controlling which extensions/tools are permitted. Sandbox for Desktop app (macOS sandbox). Permission modes: auto, approve, chat, smart_approve. `GOOSE_ALLOWLIST` env var. Prompt injection detection. Adversary mode for monitoring. |
-| **Mentat** | **Not at all** | Run commands directly via shell. No permission controls beyond git tracking. |
-| **Cline** | **Yes** | `CLINE_COMMAND_PERMISSIONS` env var with allow/deny lists for shell commands. Tool policies per tool (autoApprove/require approval/disable). Auto-approve can be toggled. VS Code extension every action requires explicit approval by default. |
-| **Continue** | **Not at all** | No shell access in current CI product. Legacy extension uses VS Code sandbox. |
-
-### Requirement 11: Session management (forking, model switching, loading/resuming)
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Session management with start, resume (`-r`), search, and history. Smart context management with auto-compaction. Sessions are single continuous conversations. Model switching supported via multi-model config. |
-| **Mentat** | **Partial** | Basic CLI sessions. No session forking, no model switching mid-conversation, no resume documented (beyond git context). |
-| **Cline** | **Yes** | `cline history` command for managing task history. Session state persists across restarts (snapshot/restore). Model can be overridden per run with `-m` flag. Task history queryable. |
-| **Continue** | **Not at all** | No session concept in current CI product. Each check run is stateless. |
-
-### Requirement 12: Human-in-the-loop checkpoints
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Permission modes: "approve" mode requires user approval for each action, "smart_approve" for selective approval. Configuration via `GOOSE_MODE`. Session-level approval flow. |
-| **Mentat** | **Not at all** | No checkpoint/approval system. Mentat edits files directly. |
-| **Cline** | **Yes** | Each file edit and terminal command requires approval by default. Plan mode vs Act mode. Auto-approve can be toggled. Checkpoints track all changes for easy undo. VS Code extension shows diffs for review. |
-| **Continue** | **Not at all** | Runs autonomously in CI. No human-in-the-loop during execution. Results are reviewed after the fact. |
-
-### Requirement 13: State persistence across restarts
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Sessions can be resumed across restarts (`goose session -r`). Configuration persists in `config.yaml`. Session history maintained with smart context management. OpenTelemetry for observability. |
-| **Mentat** | **No** | No session persistence. Each session starts fresh (though git provides code history). |
-| **Cline** | **Yes** | Session state persists across restarts. Snapshot/restore capability via SDK. Schedules persist across restarts. `cline history` for past sessions. Config in `.clinerules` and plugin configs. |
-| **Continue** | **Not at all** | Each check run is stateless. Check files are persistent (in git repo) but execution state is not. |
-
-### Requirement 14: Provider-agnostic LLM
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | 15+ providers: Anthropic, OpenAI, Google, Ollama, OpenRouter, Azure, Bedrock, SageMaker, etc. ACP providers (Claude Code, Codex). Provider abstraction in config. Multi-model configuration supported. |
-| **Mentat** | **Partial** | Primarily OpenAI GPT-4. Alternative models via litellm proxy (workaround, not native abstraction). |
-| **Cline** | **Yes** | Provider gateway via `@cline/llms` package. Anthropic, OpenAI, Google, OpenRouter (200+ models), AWS Bedrock, Azure, GCP Vertex, Cerebras, Groq, Ollama, LM Studio, any OpenAI-compatible API. Provider catalog with `getAllProviders()`, `getModelsForProvider()`. |
-| **Continue** | **Yes** | Multiple providers supported via config. Uses `@continuedev/openai-adapters` for provider abstraction. Supports Anthropic, OpenAI, Google, AWS Bedrock, Ollama, etc. Model configurable per check file. |
-
-### Requirement 15: Multiple interfaces (CLI, TUI, API)
-
-| Framework | Support | Explanation |
-|-----------|---------|-------------|
-| **Goose** | **Yes** | Desktop app (macOS, Linux, Windows), CLI, API (via ACP server mode). `goose acp` starts as ACP server. Multiple interfaces documented. |
-| **Mentat** | **Partial** | CLI only. Had a VS Code extension (mentat-vscode) but it's part of the archived project. No API, no TUI beyond basic CLI. |
-| **Cline** | **Yes** | CLI (interactive + headless), VS Code Extension, JetBrains Plugin, Kanban (web-based), SDK for custom integrations. ACP mode for other editors (Neovim, Zed). |
-| **Continue** | **Partial** | CLI (`cn`), VS Code Extension, JetBrains Plugin. API is cloud-based (continue.dev). No TUI. Current focus is CLI + CI integration. |
-
----
-
-## Source list
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [Goose GitHub Repository](https://github.com/aaif-goose/goose) | official (GitHub) |
-| 2 | [Goose Architecture Docs](https://goose-docs.ai/docs/goose-architecture/) | official (docs) |
-| 3 | [Goose Subagents Guide](https://goose-docs.ai/docs/guides/context-engineering/subagents) | official (docs) |
-| 4 | [Goose Skills Guide](https://goose-docs.ai/docs/guides/context-engineering/using-skills) | official (docs) |
-| 5 | [Goose Goosehints Guide](https://goose-docs.ai/docs/guides/context-engineering/using-goosehints) | official (docs) |
-| 6 | [Goose Config Files Guide](https://goose-docs.ai/docs/guides/config-files) | official (docs) |
-| 7 | [Goose Sessions Guide](https://goose-docs.ai/docs/guides/sessions/) | official (docs) |
-| 8 | [Goose Security Guide](https://goose-docs.ai/docs/guides/security/) | official (docs) |
-| 9 | [Mentat Archived Repository](https://github.com/AbanteAI/archive-old-cli-mentat) | official (GitHub, archived) |
-| 10 | [Mentat PyPI Page](https://pypi.org/project/mentat/) | official (PyPI) |
-| 11 | [Mentat Web Archive README (Jan 2024)](https://web.archive.org/web/20240105225309/https://github.com/AbanteAI/mentat) | official (archived) |
-| 12 | [Cline GitHub Repository](https://github.com/cline/cline) | official (GitHub) |
-| 13 | [Cline SDK Overview Docs](https://docs.cline.bot/sdk/overview) | official (docs) |
-| 14 | [Cline SDK Architecture Docs](https://docs.cline.bot/sdk/architecture/overview) | official (docs) |
-| 15 | [Cline CLI Overview Docs](https://docs.cline.bot/usage/cli-overview) | official (docs) |
-| 16 | [Cline Tools Docs](https://docs.cline.bot/sdk/tools) | official (docs) |
-| 17 | [Cline Plugins Docs](https://docs.cline.bot/sdk/plugins) | official (docs) |
-| 18 | [Cline Building an Agent Guide](https://docs.cline.bot/sdk/guides/building-an-agent) | official (docs) |
-| 19 | [Cline Creating Custom Tools Guide](https://docs.cline.bot/sdk/guides/creating-custom-tools) | official (docs) |
-| 20 | [Continue GitHub Repository](https://github.com/continuedev/continue) | official (GitHub) |
-| 21 | [Continue Docs — What is Continue](https://docs.continue.dev/) | official (docs) |
-| 22 | [Continue Check File Reference](https://docs.continue.dev/checks/reference) | official (docs) |
-| 23 | [Continue Beyond Checks — Agents](https://docs.continue.dev/mission-control/beyond-checks) | official (docs) |
-| 24 | [Continue core/package.json](https://github.com/continuedev/continue/blob/main/core/package.json) | official (source) |
-
----
-
-## Verbatim quotes
-
-- "goose is an open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM" — [Goose GitHub](https://github.com/aaif-goose/goose)
-- "Subagents are independent instances that execute tasks while keeping your main conversation clean and focused." — [Goose Subagents Guide](https://goose-docs.ai/docs/guides/context-engineering/subagents)
-- "Skills are reusable sets of instructions and resources that teach goose how to perform specific tasks." — [Goose Skills Guide](https://goose-docs.ai/docs/guides/context-engineering/using-skills)
-- "⚠️ ARCHIVED PROJECT ⚠️ This repository contains an archived version of an old command-line tool that is no longer maintained or supported." — [Mentat Archived Repository](https://github.com/AbanteAI/archive-old-cli-mentat)
-- "Cline is an AI coding agent that lives in your editor and your terminal. It can read and write files, run terminal commands, use a browser, and help you build features through natural conversation." — [Cline Docs](https://docs.cline.bot/)
-- "The Cline SDK is an open source framework for building agentic applications, and is the same harness used in the Cline IDE extensions and CLI." — [Cline SDK Docs](https://docs.cline.bot/sdk/overview)
-- "Coordinate multiple agents working together on complex tasks. A coordinator agent breaks the work into subtasks and delegates to specialist agents." — [Cline GitHub README](https://github.com/cline/cline)
-- "Source-controlled AI checks, enforceable in CI. Powered by the open-source Continue CLI." — [Continue GitHub](https://github.com/continuedev/continue)
-- "Continue runs AI checks on every pull request. Each check is a markdown file in your repo that shows up as a GitHub status check." — [Continue Docs](https://docs.continue.dev/)
-
----
-
-## Source quality flags
-
-- Source 10 (Mentat PyPI): The project is marked as archived. Useful for confirming status but not for evaluating current capabilities.
-- Source 11 (Mentat Wayback Machine): Archived snapshot from Jan 2024; reflects pre-archive state. Useful context but outdated.
-- Source 22-23 (Continue Docs): Primarily documents the new CI/checks product. The legacy VS Code extension features are not well documented in current docs.
-
----
-
-## Confidence: High
-
-All four frameworks were researched from primary sources (GitHub repos, official documentation). Goose and Cline documentation is current and comprehensive. Continue's documentation accurately reflects its pivot to CI checks. Mentat's status as archived is confirmed by both GitHub and PyPI. The key limitation is that some features (especially edge cases around hierarchy and conflict prevention) were inferred from published capabilities rather than explicit documentation, but the overall assessment is reliable.
-
----
-
-## Gaps and open questions
-
-1. **Cline three-layer hierarchy**: Cline's multi-agent teams imply a coordinator→specialist pattern, but whether this supports full 3+ layer nesting (dispatch→orchestrator→subagent→sub-subagent) is unclear from available docs.
-2. **Goose conflict prevention**: Whether Goose provides explicit file-locking or scope-assignment mechanisms for parallel subagents is not documented beyond process isolation and worktree separation.
-3. **Continue agent features**: Continue's "Agents" (beyond checks) are cloud-based and poorly documented for self-hosted use. What local agent capabilities remain from the original IDE assistant is unclear.
-4. **New Mentat (mentat.ai)**: The task specified "Mentat (by AbanteAI)" which refers to the archived CLI tool. The current Mentat at mentat.ai is a different product (AI GitHub bot) and was not evaluated. If this is what the lead researcher intended, follow-up investigation is needed.
-5. **LSP integration depth**: Cline's LSP integration is primarily through VS Code extension host. Whether SDK-level LSP tools exist outside the IDE is unclear.
-
----
-
-## Summary Comparison Table
-
-| Requirement | Goose | Mentat (archived) | Cline | Continue |
-|---|---|---|---|---|
-| **1. Three-layer hierarchy** | Partial | Not at all | Partial | Not at all |
-| **2. Config-driven orchestrators** | Yes | Not at all | Partial | Not at all |
-| **3. Parallel subagent execution** | Yes | Not at all | Yes | Not at all |
-| **4. Strict hierarchy communication** | Yes | Not at all | Partial | Not at all |
-| **5. User-to-agent mid-execution** | Yes | Partial | Yes | Not at all |
-| **6. Conflict prevention** | Partial | Not at all | Partial | Not at all |
-| **7. Role-scoped tooling** | Yes | Not at all | Yes | Not at all |
-| **8. Skills system** | Yes | Not at all | Yes | Not at all |
-| **9. LSP integration** | Not at all | Not at all | Yes | Partial |
-| **10. Shell with dir permissions** | Yes | Not at all | Yes | Not at all |
-| **11. Session management** | Yes | Partial | Yes | Not at all |
-| **12. Human-in-the-loop checkpoints** | Yes | Not at all | Yes | Not at all |
-| **13. State persistence** | Yes | No | Yes | Not at all |
-| **14. Provider-agnostic LLM** | Yes | Partial | Yes | Yes |
-| **15. Multiple interfaces** | Yes (Desktop, CLI, API) | Partial (CLI only) | Yes (CLI, IDE, Kanban, SDK) | Partial (CLI, IDE) |
-
----
-
-## Key Questions Summary
-
-### What is each framework's core architecture? How many layers of agent hierarchy?
-- **Goose**: Interface → Agent → Extensions (3-component). Subagents: main agent → subagent (1 level deep, cannot nest further).
-- **Mentat**: Single agent CLI. No hierarchy.
-- **Cline**: Layered SDK (shared → llms → agents → core). Multi-agent teams: coordinator → specialists. SDK allows custom hierarchies.
-- **Continue**: Single-agent check runner. No hierarchy.
-
-### How extensible/configurable without modifying source code?
-- **Goose**: Very — YAML config files, recipes, skills, extensions/MCP, env vars, custom distributions.
-- **Mentat**: Minimal — basic config file for model settings and file exclusions.
-- **Cline**: Very — SDK plugins, custom tools, hooks, rules, MCP servers, config CLI.
-- **Continue**: Limited — check files are markdown with YAML frontmatter; config via CLI/env.
-
-### Primary use case?
-- **Goose**: General-purpose AI agent (code, automation, research, writing)
-- **Mentat (archived)**: CLI-based AI coding assistant (context-aware code editing)
-- **Cline**: AI coding agent in IDE/terminal + SDK for custom agent apps
-- **Continue**: Source-controlled AI checks in CI/CD (pivoted from IDE assistant)
-
-### Activity level?
-- **Goose**: Very active — 45.5k stars, 4,541 commits, 134 releases, moved to Linux Foundation AAIF
-- **Mentat**: Archived — 2.6k stars, 560 commits, last code Jan 2024, archived Jan 2025
-- **Cline**: Very active — 62k stars, 5,919 commits, 260 releases, CLI v3.0.7 (May 18, 2026)
-- **Continue**: Active — 33.3k stars, 21,498 commits, 822 releases (but pivoted focus)
-
-### Language?
-- **Goose**: Rust + TypeScript
-- **Mentat (archived)**: Python (TypeScript for VS Code extension)
-- **Cline**: TypeScript (some Rust)
-- **Continue**: TypeScript (some Kotlin for JetBrains, Python)
-
----
-
-## Tool calls made
-
-1. `webfetch` — github.com/block/goose (README)
-2. `webfetch` — github.com/AbanteAI/mentat (404)
-3. `webfetch` — github.com/cline/cline (README)
-4. `webfetch` — github.com/continuedev/continue (README)
-5. `webfetch` — github.com/AbanteAI/mentat (second attempt, 404)
-6. `webfetch` — github.com/continuedev/continue/blob/main/extensions/vscode/README.md
-7. `webfetch` — goose-docs.ai/docs/quickstart
-8. `webfetch` — docs.cline.bot (overview)
-9. `webfetch` — docs.cline.bot/sdk/overview
-10. `webfetch` — goose-docs.ai/docs/category/architecture-overview
-11. `webfetch` — docs.continue.dev (what is continue)
-12. `webfetch` — goose-docs.ai/docs/goose-architecture/
-13. `webfetch` — docs.cline.bot/sdk/architecture/overview
-14. `webfetch` — github.com/continuedev/continue/blob/main/core/README.md (404)
-15. `webfetch` — pypi.org/project/mentat/
-16. `webfetch` — npmjs.com/package/mentat (403)
-17. `webfetch` — goose-docs.ai/docs/guides/context-engineering/subagents
-18. `webfetch` — docs.cline.bot/sdk/tools
-19. `webfetch` — goose-docs.ai/docs/guides/context-engineering/using-skills
-20. `webfetch` — docs.cline.bot/sdk/plugins
-21. `webfetch` — goose-docs.ai/docs/guides/config-files
-22. `webfetch` — docs.continue.dev/checks/quickstart
-23. `webfetch` — goose-docs.ai/docs/guides/security/
-24. `webfetch` — docs.cline.bot/usage/cli-overview
-25. `webfetch` — github.com/AbanteAI/archive-old-cli-mentat
-26. `webfetch` — docs.cline.bot/sdk/guides/building-an-agent
-27. `webfetch` — docs.cline.bot/sdk/guides/creating-custom-tools
-28. `webfetch` — github.com/continuedev/continue/blob/main/core/package.json
-29. `webfetch` — raw.githubusercontent.com/AbanteAI/archive-old-cli-mentat/main/README.md
-30. `webfetch` — docs.continue.dev/mission-control/beyond-checks
-31. `webfetch` — raw.githubusercontent.com/AbanteAI/archive-old-cli-mentat/main/mentat/__init__.py
-32. `webfetch` — raw.githubusercontent.com/AbanteAI/archive-old-cli-mentat/main/pyproject.toml
-33. `webfetch` — web.archive.org/web/20240105225309/https://github.com/AbanteAI/mentat
-34. `webfetch` — mentat.ai (transport error)
-35. `webfetch` — docs.cline.bot/getting-started/authorizing-with-cline
-36. `webfetch` — raw.githubusercontent.com/AbanteAI/archive-old-cli-mentat/main/docs/overview.md (404)
diff --git a/research/emerging-specialized.md b/research/emerging-specialized.md
deleted file mode 100644
index 6be8f18..0000000
--- a/research/emerging-specialized.md
+++ /dev/null
@@ -1,473 +0,0 @@
-# Subagent Report: Emerging & Specialized AI Agent Harnesses
-
-## Research summary
-
-This report evaluates **OpenCode** (now **Crush**), **Plandex**, **GPT-Engineer**, **Claude Code**, and several other notable open-source AI coding agents against a 15-point requirements checklist for a multi-layered agent orchestration harness. Of all frameworks evaluated, **Claude Code** comes closest to the full requirements but is not fully open source and lacks a native three-layer dispatch->orchestrator->subagent hierarchy. **Plandex** is strong for large-scale plan-then-execute workflows but is single-agent. **Crush** (OpenCode's successor) is a well-architected terminal agent with LSP/MCP/skills but lacks hierarchy. **GPT-Engineer** is archived and unsuitable. **Bolt.diy** is a web app builder, not an agent harness. **Sweep** has pivoted to a JetBrains plugin.
-
----
-
-## Findings
-
-### 1. OpenCode (archived) → Crush (successor)
-
-**GitHub**: [opencode-ai/opencode](https://github.com/opencode-ai/opencode) (archived Sep 18, 2025) → [charmbracelet/crush](https://github.com/charmbracelet/crush) (24.4k stars, active)
-
-**Language**: Go
-
-**Architecture**: OpenCode was a Go-based CLI/TUI coding agent built with Charm's Bubble Tea framework. It was archived in September 2025 and the project continued as **Crush** by the Charm team. Crush is actively maintained (v0.70.0, May 18, 2026) with 3,380+ commits.
-
-Crush's architecture is a **single-agent loop** with tool-calling capabilities. It supports spawning sub-tasks via the `agent` tool but does not have a built-in multi-layer hierarchy. It provides:
-
-- Interactive TUI + CLI + non-interactive prompt mode
-- Multi-provider LLM support (Anthropic, OpenAI, Google, Groq, OpenRouter, AWS Bedrock, Azure, local models)
-- LSP integration with configurable language server support
-- MCP protocol support (stdio, http, sse)
-- Skills via the Agent Skills open standard
-- Session management (save/load/switch sessions, SQLite-backed)
-- Plugin system through MCP and skills
-- Hooks (preliminary support)
-- Configurable permissions (`allowed_tools`, tool allow/deny)
-- Desktop notifications
-- Provider auto-updates from Catwalk database
-
-**Notable features for Dispatch comparison**: Crush reads `.claude/skills/` and `.cursor/skills/` directories for compatibility with Claude Code skills. It supports `.crushignore` files. It has `initialize_as` option to create AGENTS.md/CRUSH.md context files.
-
-**Confidence**: High — well-documented, active project, extensive README.
-
----
-
-### 2. Plandex
-
-**GitHub**: [plandex-ai/plandex](https://github.com/plandex-ai/plandex) (15.4k stars, active, 1,483 commits)
-
-**Language**: Go (93.4%)
-
-**Architecture**: Plandex is a terminal-based AI development tool with a **plan-and-execute** workflow. It is a single-agent system with a cumulative diff review sandbox. Its architecture is:
-
-- **Plan branch**: AI proposes changes in a sandbox, kept separate from project files
-- **Apply phase**: User reviews and applies the cumulative diff
-- **Autonomous loop**: Can auto-execute commands, detect failures, debug, and retry
-
-Plandex handles up to 2M tokens of effective context via selective loading. It uses tree-sitter for project map generation (30+ languages). It has built-in version control for plans (branches, diffs). It integrates with git for commit message generation.
-
-**Key details**:
-- CLI + REPL with fuzzy auto-complete
-- Multi-provider: Anthropic, OpenAI, Google, OpenRouter, open source models
-- Context caching for all major providers
-- Configurable autonomy levels (full auto → step-by-step review)
-- Automated debugging of terminal commands and browser apps
-- Cloud hosted mode is winding down; self-hosted/local Docker mode is primary
-- No API mode, no TUI (terminal REPL only)
-
-**Confidence**: High — well-documented, active community, recent releases.
-
----
-
-### 3. GPT-Engineer
-
-**GitHub**: [AntonOsika/gpt-engineer](https://github.com/AntonOsika/gpt-engineer) (55.2k stars, **archived** Apr 22, 2026)
-
-**Language**: Python (98.8%)
-
-**Architecture**: GPT-Engineer was a CLI platform for code generation experimentation. It used a simple single-agent prompt→generate loop. The project was archived in April 2026 and the last release was v0.3.1 (June 6, 2024). The README explicitly directs users to "aider" for a well-maintained CLI or "lovable.dev" for the commercial evolution.
-
-**Capabilities**:
-- Single-agent, one-shot code generation from natural language prompts
-- Support for OpenAI, Anthropic, and open source models
-- Benchmark support (APPS, MBPP)
-- Pre-prompt overrides for agent identity
-- Vision support via image inputs
-- Docker support
-
-**Confidence**: High — project is archived, low relevance for a new harness build.
-
----
-
-### 4. Claude Code (Anthropic)
-
-**GitHub**: [anthropics/claude-code](https://github.com/anthropics/claude-code) (125k stars, very active, 627 commits)
-
-**Language**: Shell 47.1%, Python 29.2%, TypeScript 17.7% (Note: this repo contains the installer, plugins, examples, and documentation; the **core agent engine is proprietary** and not fully open source.)
-
-**Architecture**: Claude Code is a sophisticated agentic coding tool with a layered architecture:
-
-1. **Main session**: The primary agent loop that the user interacts with
-2. **Subagents**: Specialized agents spawned from the main session, each with:
- - Its own context window
- - Custom system prompts via YAML frontmatter in Markdown files
- - Restricted/permissions-scoped tool sets
- - Configurable models (sonnet/opus/haiku or full model IDs)
- - Independent permission modes (default, acceptEdits, auto, dontAsk, bypassPermissions, plan)
- - Persistent memory (user/project/local scope)
- - Preloaded skills
- - Optional git worktree isolation
-3. **Agent teams** (experimental, behind feature flag): Multiple full Claude Code sessions that coordinate via a shared task list, with peer-to-peer messaging via mailbox system
-
-**Surface area**: Terminal CLI, VS Code extension, JetBrains plugin, Desktop app, Web (claude.ai/code), Slack integration, GitHub Actions, GitLab CI/CD, iOS app
-
-**Key subsystems**:
-- **Skills system**: Full Agent Skills open standard support, directory-based (`.claude/skills/`), personal/project/plugin/managed scopes, YAML frontmatter with description/tools/context/agent fields, dynamic context injection via `` !`command` ``, argument substitution
-- **Memory system**: CLAUDE.md files (project/user/managed/org scopes), `.claude/rules/` for path-scoped instructions, auto memory (Claude writes learnings across sessions)
-- **Hooks**: PreToolUse, PostToolUse, SubagentStart/Stop, TeammateIdle, TaskCreated, TaskCompleted — shell commands at lifecycle events
-- **Permissions**: Tiered system (allow/ask/deny rules), wildcard matching, Bash/Read/Edit/WebFetch/MCP scoping, sandboxing (OS-level isolation), auto mode (ML classifier)
-- **MCP**: Model Context Protocol for external tool integration
-- **Agent SDK**: Python and TypeScript SDKs for building custom agents with Claude Code's tools and agent loop
-
-**Provider support**: Primarily uses Claude models but supports Amazon Bedrock, Google Vertex AI, Microsoft Azure AI Foundry as third-party backends.
-
----
-
-### 5. Bolt.new / Bolt.diy
-
-**GitHub**: [stackblitz/bolt.new](https://github.com/stackblitz/bolt.new) (16.4k stars) / [stackblitz-labs/bolt.diy](https://github.com/stackblitz-labs/bolt.diy) (19.4k stars)
-
-**Language**: TypeScript
-
-**Architecture**: Bolt.new is a web-based AI-powered full-stack application generator using StackBlitz's WebContainers. bolt.diy is the community fork that supports any LLM. Both are **web application builders**, not agent harnesses or orchestrators.
-
-- Single-agent prompt→generate→preview loop
-- No agent hierarchy
-- No orchestrator or subagent concepts
-- Primarily browser-based (with Electron desktop app)
-- Supports 19+ LLM providers in bolt.diy
-- File locking system to prevent conflicts
-- Git integration for clone/import/deploy
-- MCP support
-- Diff view for AI changes
-
-**Confidence**: High — well-documented, but not applicable as an agent harness.
-
----
-
-### 6. Sweep
-
-**GitHub**: [sweepai/sweep](https://github.com/sweepai/sweep) (7.7k stars)
-
-**Language**: Python, Jupyter Notebook, TypeScript
-
-**Note**: Sweep was originally a GitHub app for automated PR creation from issues. It has since pivoted to an AI coding assistant for JetBrains IDEs. The open source repo is largely dormant. Not applicable as a general agent harness.
-
----
-
-### 7. Other Notable Frameworks Not Found
-
-**Kortix Suna**: Could not locate an active GitHub repository under sunaify/suna or similar names.
-
-**Devon (entelligence-ai/devon)**: Could not locate — the GitHub org/user was not found (404).
-
----
-
-## Requirements Checklist
-
-### 1. Three-layer hierarchy (dispatch → orchestrator → subagent)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ❌ Not supported | Single-agent loop; has `agent` tool for sub-tasks but no orchestrator layer |
-| **Plandex** | ❌ Not supported | Single-agent plan→execute loop |
-| **GPT-Engineer** | ❌ Not supported | Single-agent prompt→generate |
-| **Claude Code** | ⚠️ Partial | 2 layers: main agent → subagents. Agent teams (experimental) add peer coordination but not 3-tier hierarchy |
-| **Bolt.diy** | ❌ Not supported | Single-agent web app builder |
-| **Sweep** | ❌ Not supported | Single-agent GitHub PR generator (now JetBrains plugin) |
-
-### 2. Config-driven orchestrators
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ❌ Not supported | No orchestrator concept; config is for providers/model/agents |
-| **Plandex** | ❌ Not supported | Config is limited to model providers and autonomy level |
-| **GPT-Engineer** | ❌ Not supported | Pre-prompt override only |
-| **Claude Code** | ⚠️ Partial | Subagents defined via YAML frontmatter in `.md` files with tools/models/prompts/permissions. No subagent template spawning from orchestrator config |
-| **Bolt.diy** | ❌ Not supported | Provider config only |
-| **Sweep** | ❌ Not supported | — |
-
-### 3. Parallel subagent execution
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ❌ Not supported | Sub-agent tool is sequential |
-| **Plandex** | ❌ Not supported | Single-threaded plan→execute |
-| **GPT-Engineer** | ❌ Not supported | Single-threaded |
-| **Claude Code** | ✅ Supported | Subagents run in parallel; multiple background agents can run concurrently. Agent teams spawn multiple independent sessions |
-| **Bolt.diy** | ❌ Not supported | Single prompt→response |
-| **Sweep** | ❌ Not supported | — |
-
-### 4. Strict hierarchy communication (subagents only talk to parent)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ⚠️ Partial | Agent tool calls return results; no peer-to-peer, but also no multi-layer parent chain |
-| **Plandex** | N/A | No subagents |
-| **GPT-Engineer** | N/A | No subagents |
-| **Claude Code** | ✅ Supported | Subagents report to parent only; no peer-to-peer for subagents. Note: Agent teams (experimental) intentionally allow peer-to-peer messaging |
-| **Bolt.diy** | N/A | No subagents |
-| **Sweep** | N/A | — |
-
-### 5. User-to-agent messaging mid-execution
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ❌ Not supported | User types at the main session; cannot message a sub-agent mid-task |
-| **Plandex** | ⚠️ Partial | User can interrupt and redirect during plan step, but not while AI is executing |
-| **GPT-Engineer** | ❌ Not supported | Batch process |
-| **Claude Code** | ✅ Supported | Users can interact with subagents directly via Shift+Down in in-process mode; can message agent team teammates directly |
-| **Bolt.diy** | ❌ Not supported | Single prompt-response |
-| **Sweep** | ❌ Not supported | — |
-
-### 6. Conflict prevention (non-overlapping file scopes)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ❌ Not supported | No scope assignment |
-| **Plandex** | ⚠️ Partial | Cumulative diff sandbox prevents conflicts by keeping AI changes separate until user applies them |
-| **GPT-Engineer** | ❌ Not supported | — |
-| **Claude Code** | ⚠️ Partial | Git worktrees for subagent isolation; file locking in agent teams. No orchestrator-driven scope assignment |
-| **Bolt.diy** | ✅ Supported | File locking system prevents concurrent edits during AI code generation |
-| **Sweep** | ❌ Not supported | — |
-
-### 7. Role-scoped tooling
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | Different agents can have different tool sets via `agents` config with `tools` field |
-| **Plandex** | ❌ Not supported | Single agent with all tools |
-| **GPT-Engineer** | ❌ Not supported | Single agent |
-| **Claude Code** | ✅ Supported | Full role-scoped tooling via `tools` and `disallowedTools` frontmatter in subagent definitions |
-| **Bolt.diy** | ❌ Not supported | Single agent |
-| **Sweep** | ❌ Not supported | — |
-
-### 8. Skills system (injectable markdown instructions, directory-based)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | Supports Agent Skills open standard. Reads from `.crush/skills/`, `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, plus user-level paths |
-| **Plandex** | ❌ Not supported | No skills system |
-| **GPT-Engineer** | ⚠️ Partial | Has pre-prompt override files but not a structured skills system |
-| **Claude Code** | ✅ Supported | Full Agent Skills open standard support, YAML frontmatter, personal/project/plugin/managed scopes, dynamic context injection, argument substitution, preloading into subagents |
-| **Bolt.diy** | ❌ Not supported | No skills system |
-| **Sweep** | ❌ Not supported | — |
-
-### 9. LSP integration
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | LSP integration with multi-language support via configurable language server commands |
-| **Plandex** | ⚠️ Partial | Tree-sitter for syntax validation and project maps, but no LSP diagnostics tool |
-| **GPT-Engineer** | ❌ Not supported | — |
-| **Claude Code** | ✅ Supported | LSP through IDE integrations (VS Code, JetBrains); diagnostics tool available to agents |
-| **Bolt.diy** | ❌ Not supported | — |
-| **Sweep** | ❌ Not supported | — |
-
-### 10. Shell access with directory permissions
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ⚠️ Partial | Has `allowed_tools` permission allowlist but no directory-based permission scoping. Crush has `--yolo` flag to bypass |
-| **Plandex** | ❌ Not supported | Has shell access but no permission system |
-| **GPT-Engineer** | ❌ Not supported | Shell execution without permissions |
-| **Claude Code** | ✅ Supported | Full permission system: allow/ask/deny rules, wildcard matching, absolute/project-relative/home-relative path patterns, sandboxing (OS-level filesystem/network isolation), auto mode with ML classifier |
-| **Bolt.diy** | ❌ Not supported | — |
-| **Sweep** | ❌ Not supported | — |
-
-### 11. Session management (forking, model switching, resume)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | Session save/load/switch, model switching mid-session, session persistence via SQLite |
-| **Plandex** | ✅ Supported | Plan version control with branching; model switching supported |
-| **GPT-Engineer** | ❌ Not supported | No session persistence |
-| **Claude Code** | ✅ Supported | Full session management: resume (`-r`, `-c`), fork (`/fork`), model switching (`/model`), chat history persistence, auto memory, `/compact` for context management |
-| **Bolt.diy** | ⚠️ Partial | Chat history via file system, no forking or model switching |
-| **Sweep** | ❌ Not supported | — |
-
-### 12. Human-in-the-loop checkpoints
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ❌ Not supported | No checkpoint system; permission prompts are per-tool |
-| **Plandex** | ✅ Supported | Configurable autonomy from full-auto to step-by-step approval; user reviews cumulative diff before applying |
-| **GPT-Engineer** | ❌ Not supported | No HITL |
-| **Claude Code** | ✅ Supported | Permission prompts, permission modes (default/acceptEdits/plan/auto), plan mode for review-before-edit. Checkpoints via hooks for custom approval workflows |
-| **Bolt.diy** | ❌ Not supported | Auto-approves all AI changes |
-| **Sweep** | ❌ Not supported | — |
-
-### 13. State persistence (sessions, plans, artifacts across restarts)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | SQLite-based session persistence; project-specific context files |
-| **Plandex** | ✅ Supported | Plans persist and can be resumed; cumulative diff sandbox stores pending changes |
-| **GPT-Engineer** | ❌ Not supported | No persistence |
-| **Claude Code** | ✅ Supported | Sessions persist in `~/.claude/projects/`; auto memory persists across sessions; CLAUDE.md files are disk-based |
-| **Bolt.diy** | ⚠️ Partial | Project snapshots via browser storage and file system |
-| **Sweep** | ❌ Not supported | — |
-
-### 14. Provider-agnostic LLM
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | 15+ providers including Anthropic, OpenAI, Google, Groq, OpenRouter, AWS Bedrock, Azure, local models, Ollama, LM Studio |
-| **Plandex** | ✅ Supported | Anthropic, OpenAI, Google, OpenRouter, open source providers; OpenRouter as primary gateway |
-| **GPT-Engineer** | ✅ Supported | OpenAI, Anthropic, open source/local models |
-| **Claude Code** | ⚠️ Partial | Primarily Claude models; supports Amazon Bedrock, Google Vertex AI, Microsoft Azure as third-party backends |
-| **Bolt.diy** | ✅ Supported | 19+ providers including OpenAI, Anthropic, Google, Ollama, OpenRouter, DeepSeek, Groq, etc. |
-| **Sweep** | ❌ Not applicable | — |
-
-### 15. Multiple interfaces (CLI, TUI, API)
-
-| Framework | Status | Notes |
-|-----------|--------|-------|
-| **OpenCode → Crush** | ✅ Supported | Interactive TUI, CLI (non-interactive mode with `-p`), scripting support |
-| **Plandex** | ⚠️ Partial | REPL (TUI-like interactive mode) and CLI commands. No API mode |
-| **GPT-Engineer** | ⚠️ Partial | CLI only |
-| **Claude Code** | ✅ Supported | Terminal CLI (interactive + `-p`), VS Code extension, JetBrains plugin, Desktop app, Web UI, Slack, Agent SDK (Python/TypeScript API), GitHub Actions, GitLab CI/CD |
-| **Bolt.diy** | ⚠️ Partial | Web UI, Electron desktop app, no API |
-| **Sweep** | ❌ Not applicable | — |
-
----
-
-## Summary Comparison Table
-
-| Requirement | OpenCode→Crush | Plandex | GPT-Engineer | Claude Code | Bolt.diy |
-|---|---|---|---|---|---|
-| **1. Three-layer hierarchy** | ❌ | ❌ | ❌ | ⚠️ Partial | ❌ |
-| **2. Config-driven orchestrators** | ❌ | ❌ | ❌ | ⚠️ Partial | ❌ |
-| **3. Parallel subagent execution** | ❌ | ❌ | ❌ | ✅ | ❌ |
-| **4. Strict hierarchy communication** | N/A | N/A | N/A | ✅ | N/A |
-| **5. Mid-execution user messaging** | ❌ | ⚠️ | ❌ | ✅ | ❌ |
-| **6. Conflict prevention** | ❌ | ⚠️ | ❌ | ⚠️ | ✅ |
-| **7. Role-scoped tooling** | ✅ | ❌ | ❌ | ✅ | ❌ |
-| **8. Skills system** | ✅ | ❌ | ⚠️ | ✅ | ❌ |
-| **9. LSP integration** | ✅ | ⚠️ | ❌ | ✅ | ❌ |
-| **10. Shell + directory permissions** | ⚠️ | ❌ | ❌ | ✅ | ❌ |
-| **11. Session management** | ✅ | ✅ | ❌ | ✅ | ⚠️ |
-| **12. HITL checkpoints** | ❌ | ✅ | ❌ | ✅ | ❌ |
-| **13. State persistence** | ✅ | ✅ | ❌ | ✅ | ⚠️ |
-| **14. Provider-agnostic LLM** | ✅ | ✅ | ✅ | ⚠️ | ✅ |
-| **15. Multiple interfaces** | ✅ | ⚠️ | ⚠️ | ✅ | ⚠️ |
-| **Open source** | ✅ (MIT) | ✅ (MIT) | ✅ (MIT) | ⚠️ (Partial) | ✅ (MIT) |
-| **Language** | Go | Go | Python | Multi | TypeScript |
-| **Activity** | ✅ Very Active | ✅ Active | ❌ Archived | ✅ Very Active | ✅ Active |
-| **Stars** | 24.4k (Crush) | 15.4k | 55.2k | 125k | 19.4k |
-
----
-
-## Key Questions Answered
-
-### 1. Core architecture and agent hierarchy depth
-
-- **Crush**: Single-agent TUI/CLI with sub-task spawning (2 layers max). No built-in orchestrator concept.
-- **Plandex**: Single-agent plan→execute loop. No hierarchy.
-- **GPT-Engineer**: Single-agent prompt→generate. No hierarchy.
-- **Claude Code**: Main agent → subagents (2 layers). Experimental agent teams add peer-level coordination. No native 3-tier dispatch→orchestrator→subagent.
-- **Bolt.diy**: Single-agent web app builder. No hierarchy.
-
-### 2. Extensibility without source modification
-
-- **Crush**: JSON config files, skills via markdown, MCP servers, LSP config. Moderate extensibility.
-- **Plandex**: Model/provider config, autonomy level settings. Limited extensibility.
-- **GPT-Engineer**: Pre-prompt overrides only. Very limited.
-- **Claude Code**: Most extensible — YAML frontmatter subagents, skills, hooks, MCP, permission rules via JSON config files, managed settings for org-wide policy, plugin system. No source modification needed.
-- **Bolt.diy**: Provider config, some environment variables. Limited.
-
-### 3. Primary use case
-
-- **Crush**: General-purpose AI coding assistant for terminal users
-- **Plandex**: Large-scale, multi-step coding tasks requiring plan review and diff sandboxing
-- **GPT-Engineer**: (Archived) Experimental code generation platform
-- **Claude Code**: Production AI coding assistant with enterprise features, team collaboration, and multi-surface support
-- **Bolt.diy**: Rapid full-stack web application prototyping in the browser
-
-### 4. Project activity
-
-- **Crush**: Very active — v0.70.0 (May 18, 2026), 24.4k stars, 3,380+ commits, Charm ecosystem backing
-- **Plandex**: Active — v2.2.1 (Jul 16, 2025), 15.4k stars, 1,483 commits, active Discord
-- **GPT-Engineer**: Archived — last release v0.3.1 (Jun 6, 2024), 55.2k stars, read-only
-- **Claude Code**: Very active — 125k stars, 627 commits, active development, Anthropic backing
-- **Bolt.diy**: Active — v1.0.0 (May 12, 2025), 19.4k stars, community-driven
-
-### 5. Language
-
-- **Crush**: Go
-- **Plandex**: Go
-- **GPT-Engineer**: Python
-- **Claude Code**: Shell/Python/TypeScript (installer + plugins); core engine is proprietary binary
-- **Bolt.diy**: TypeScript
-
-### 6. Other notable open-source agent harnesses (2024-2025)
-
-- **Crush** (charmbracelet/crush) — The most notable new entrant, as successor to OpenCode. Go-based, well-architected, Charm ecosystem.
-- **Bolt.diy** (stackblitz-labs/bolt.diy) — Significant as a community fork enabling any LLM with Bolt.new's WebContainer architecture.
-- **Claude Code Agent SDK** — Released mid-2025, enables building custom agents with Claude Code's tool set. Available in Python and TypeScript.
-
-Could not locate active repositories for **Kortix Suna** or **Devon** as of May 2026.
-
----
-
-## Source list
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [OpenCode GitHub](https://github.com/opencode-ai/opencode) | GitHub |
-| 2 | [Crush GitHub](https://github.com/charmbracelet/crush) | GitHub |
-| 3 | [Plandex GitHub](https://github.com/plandex-ai/plandex) | GitHub |
-| 4 | [GPT-Engineer GitHub](https://github.com/AntonOsika/gpt-engineer) | GitHub |
-| 5 | [Claude Code GitHub](https://github.com/anthropics/claude-code) | GitHub |
-| 6 | [Claude Code Overview](https://code.claude.com/docs/en/overview) | Official docs |
-| 7 | [Claude Code Subagents](https://code.claude.com/docs/en/sub-agents) | Official docs |
-| 8 | [Claude Code Skills](https://code.claude.com/docs/en/skills) | Official docs |
-| 9 | [Claude Code Memory](https://code.claude.com/docs/en/memory) | Official docs |
-| 10 | [Claude Code Agent SDK](https://code.claude.com/docs/en/agent-sdk/overview) | Official docs |
-| 11 | [Claude Code Settings](https://code.claude.com/docs/en/settings) | Official docs |
-| 12 | [Claude Code Permissions](https://code.claude.com/docs/en/permissions) | Official docs |
-| 13 | [Claude Code Agent Teams](https://code.claude.com/docs/en/agent-teams) | Official docs |
-| 14 | [Claude Code Quickstart](https://code.claude.com/docs/en/quickstart) | Official docs |
-| 15 | [Bolt.new GitHub](https://github.com/stackblitz/bolt.new) | GitHub |
-| 16 | [Bolt.diy GitHub](https://github.com/stackblitz-labs/bolt.diy) | GitHub |
-| 17 | [Sweep GitHub](https://github.com/sweepai/sweep) | GitHub |
-| 18 | [Dispatch Requirements](file:///home/tradam/projects/dispatch/requirements.md) | Project file |
-
----
-
-## Verbatim quotes
-
-- "This repository is no longer maintained and has been archived for provenance. The project has continued under the name Crush" — [OpenCode GitHub](https://github.com/opencode-ai/opencode)
-- "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster" — [Claude Code GitHub](https://github.com/anthropics/claude-code)
-- "Subagents are specialized AI assistants that handle specific types of tasks." — [Claude Code Subagents](https://code.claude.com/docs/en/sub-agents)
-- "Agent teams let you coordinate multiple Claude Code instances working together. One session acts as the team lead, coordinating work, assigning tasks, and synthesizing results." — [Claude Code Agent Teams](https://code.claude.com/docs/en/agent-teams)
-- "Plandex is a terminal-based AI development tool that can plan and execute large coding tasks that span many steps and touch dozens of files." — [Plandex GitHub](https://github.com/plandex-ai/plandex)
-- "Crush: Your new coding bestie, now available in your favourite terminal. Your tools, your code, and your workflows, wired into your LLM of choice." — [Crush GitHub](https://github.com/charmbracelet/crush)
-- "Claude Code supports fine-grained permissions so that you can specify exactly what the agent is allowed to do and what it cannot." — [Claude Code Permissions](https://code.claude.com/docs/en/permissions)
-- "GPT-Engineer lets you: Specify software in natural language, Sit back and watch as an AI writes and executes the code" — [GPT-Engineer GitHub](https://github.com/AntonOsika/gpt-engineer)
-- "This repository was archived by the owner on Apr 22, 2026. It is now read-only." — [GPT-Engineer GitHub](https://github.com/AntonOsika/gpt-engineer)
-- "Skills extend what Claude can do. Create a SKILL.md file with instructions, and Claude adds it to its toolkit." — [Claude Code Skills](https://code.claude.com/docs/en/skills)
-
----
-
-## Source quality flags
-
-- **Claude Code GitHub repo**: The repository (anthropics/claude-code) contains primarily installer scripts, plugins, examples, and documentation — the core agent engine is proprietary and distributed via `npm`/native installers. The docs at code.claude.com are official Anthropic documentation.
-- **GPT-Engineer**: Archived project; README explicitly directs users to other tools. Not representative of current capabilities.
-- **Sweep**: README indicates project has pivoted to a JetBrains plugin. The open source repo is not actively maintained for the use case being evaluated.
-
----
-
-## Confidence: High
-
-All major frameworks evaluated have been thoroughly researched via their GitHub repositories and official documentation. Claude Code's proprietary core limits the depth of source-code-level analysis, but its public documentation is extensive and detailed. Frameworks like Kortix Suna and Devon could not be located, indicating they may have been renamed, deprecated, or are not publicly available under those names.
-
----
-
-## Gaps and open questions
-
-1. **Kortix Suna** and **Devon** could not be located — they may exist under different GitHub orgs, have been renamed, or are not publicly available.
-2. **Claude Code's core architecture** is not fully open source; the proprietary agent engine cannot be audited for architectural details. The analysis is based on publicly documented behavior.
-3. **Claude Code's Agent SDK** (released mid-2025) represents a new category — it provides programmatic access to Claude Code's agent loop. A follow-up investigation could evaluate it as an alternative to building from scratch.
-4. **Crush** is still in v0.x (v0.70.0) — some features like hooks are marked as "preliminary" and may not be production-ready.
-5. **None of the evaluated frameworks** provide a native three-layer (dispatch→orchestrator→subagent) architecture. This appears to be a novel design not present in existing open-source tools.
-
----
-
-## Tool calls made
-
-Web fetches: 20 (OpenCode, Plandex, GPT-Engineer, Claude Code, Sweep, Devon attempted, Bolt.new, Plandex docs, Claude Code overview, sub-agents, skills, memory, Agent SDK, settings, permissions, agent teams, quickstart, Crush, Bolt.diy, Sweep README)
diff --git a/research/general-agent-platforms.md b/research/general-agent-platforms.md
deleted file mode 100644
index a7f1bd4..0000000
--- a/research/general-agent-platforms.md
+++ /dev/null
@@ -1,299 +0,0 @@
-# Subagent Report: General Agent Platforms (SuperAGI, Agency Swarm, Semantic Kernel)
-
-## Research summary
-
-This report evaluates three open-source general AI agent platforms — **SuperAGI**, **Agency Swarm**, and **Microsoft Semantic Kernel** — against 15 specific requirements for an agent harness architecture. SuperAGI is a single-agent autonomous agent framework with a GUI and tool marketplace, not designed for hierarchical multi-agent orchestration. Agency Swarm is a Python multi-agent orchestration framework built on the OpenAI Agents SDK, supporting structured communication flows with parallel execution. Microsoft Semantic Kernel is an enterprise-grade SDK (C#/Python/Java) for building AI agents and multi-agent systems, now succeeded by Microsoft Agent Framework. None of the three fully support all 15 requirements; Agency Swarm comes closest for hierarchical orchestration needs, while Semantic Kernel provides the richest SDK for plugin-extensible agent systems.
-
-**Confidence**: High — all findings are based on official GitHub repositories, documentation sites, and framework README files.
-
----
-
-## Findings
-
-### 1. SuperAGI
-
-#### Core Architecture
-
-SuperAGI is a **dev-first open-source autonomous AI agent framework** written in Python. It is designed for building, managing, and running single autonomous agents with tool augmentation. The architecture uses a Celery task queue, PostgreSQL database, Redis for caching, and multiple vector store options (Pinecone, Weaviate, Chroma, Qdrant). Agents operate as independent ReAct (Reasoning + Acting) loops with goals, instructions, constraints, and tools.
-
-- **Language**: Python
-- **GitHub stars**: ~17.5k, 2,342 commits
-- **Latest release**: v0.0.11 (appears stale — README states "Under Development!")
-- **Primary use case**: Running individual autonomous agents with tool augmentation
-- **Architecture diagrams** show a single agent workflow with tool integration, not multi-agent hierarchy
-
-Key architectural components visible in the codebase:
-- `superagi/agent/` — single-agent execution loop with prompt building, tool execution, task queue
-- `config_template.yaml` — YAML-based configuration for system settings (API keys, model, DB, storage)
-- `gui/` — React-based graphical user interface
-- `tools.json` — tool definitions
-
-[Source: SuperAGI GitHub README](https://github.com/TransformerOptimus/SuperAGI)
-[Source: SuperAGI config_template.yaml](https://raw.githubusercontent.com/TransformerOptimus/SuperAGI/main/config_template.yaml)
-
-#### Requirements Checklist
-
-| # | Requirement | Status | Explanation |
-|---|-------------|--------|-------------|
-| 1 | **Three-layer hierarchy** | **Not supported** | SuperAGI is a single-agent framework. No orchestrator-subagent hierarchy exists. Multiple agents can run but are independent. |
-| 2 | **Config-driven orchestrators** | **Not supported** | System config (API keys, model, DB) is YAML-driven, but agent definitions, tools, and workflows are defined in Python code, not config files. |
-| 3 | **Parallel subagent execution** | **Partially supported** | README states "You can run concurrent agents seamlessly" but these are independent agent runs, not orchestrated subagents. No parent-child coordination of parallelism. |
-| 4 | **Strict hierarchy communication** | **Not supported** | No inter-agent communication at all. Each agent is independent. |
-| 5 | **User-to-agent messaging mid-execution** | **Partially supported** | "Action Console" allows user interaction by giving agents input and permissions, but this is at the GUI level, not arbitrary injection to running agents. |
-| 6 | **Conflict prevention** | **Not supported** | No mechanism for assigning non-overlapping file scopes to parallel agents. |
-| 7 | **Role-scoped tooling** | **Partially supported** | Agents can be configured with different toolkits from the marketplace, but this is done through the GUI/database, not through a code-level role system. |
-| 8 | **Skills system (markdown instructions)** | **Partially supported** | Uses prompt templates with variable substitution (`{goals}`, `{instructions}`, `{constraints}`), but not a markdown file-based skills directory system. |
-| 9 | **LSP integration** | **Not supported** | No Language Server Protocol integration. |
-| 10 | **Shell access with permissions** | **Not supported** | No built-in shell tool. Tools are pre-built plugins from the marketplace. |
-| 11 | **Session management** | **Partially supported** | "Agent Memory Storage" for learning/adaptation, "Performance Telemetry" for insights. No chat forking or model switching mid-conversation. |
-| 12 | **Human-in-the-loop checkpoints** | **Partially supported** | Action Console provides some user input/permissions, but no configurable checkpoint mechanism. |
-| 13 | **State persistence** | **Partially supported** | Uses PostgreSQL for agent state and file storage for workspace artifacts (`RESOURCES_OUTPUT_ROOT_DIR`). Sessions persist via DB. |
-| 14 | **Provider-agnostic LLM** | **Supported** | Supports OpenAI, Google PaLM, Replicate, HuggingFace. Configurable via `config_template.yaml`. Local LLM support via Text Generation Web UI. |
-| 15 | **Multiple interfaces** | **Supported** | Has GUI (React web interface), CLI (`cli2.py`), and REST API (via FastAPI/Postman docs). |
-
----
-
-### 2. Agency Swarm
-
-#### Core Architecture
-
-Agency Swarm is a **Python multi-agent orchestration framework** built on the OpenAI Agents SDK. It organizes agents into structured communication flows that mirror real-world organizational structures. Agents are defined with roles, instructions (from markdown files), tools, and MCP server support. Communication flows are directional tuples that define which agents can message which.
-
-- **Language**: Python (97.8%), some JavaScript/TypeScript for TUI
-- **GitHub stars**: ~4.4k, 2,412 commits
-- **Latest release**: v1.9.8 (May 6, 2026 — very active)
-- **Primary use case**: Multi-agent orchestration with structured communication patterns
-- **Python**: 3.12+
-
-Key features from the documentation:
-- **Communication flows**: Directional paths using `(sender, receiver)` tuples. Supports Handoff and Orchestrator-Worker patterns.
-- **Parallel execution**: "the CEO can assign tasks to both Developer and Virtual Assistant, so they will run in parallel in different threads and come back with their results to the CEO"
-- **Per-agent configuration**: Each agent has its own `instructions.md`, `files/`, `tools/`, `schemas/` directories
-- **State persistence**: `load_threads_callback` and `save_threads_callback` for thread persistence
-- **Multiple run modes**: TUI, CopilotKit/AG-UI web interface, FastAPI HTTP API, async programmatic
-
-Key architecture code pattern:
-```python
-agency = Agency(
- ceo, dev, # Entry points
- communication_flows=[
- (ceo, dev), # Director can delegate to developer
- (ceo, va), # Director can delegate to virtual assistant
- ],
- shared_instructions="./agency_manifesto.md",
- shared_tools=[get_current_time],
-)
-```
-
-[Source: Agency Swarm GitHub README](https://github.com/VRSEN/agency-swarm)
-[Source: Agency Swarm Docs - Overview](https://agency-swarm.ai/core-framework/agencies/overview)
-[Source: Agency Swarm Docs - Communication Flows](https://agency-swarm.ai/core-framework/agencies/communication-flows)
-
-#### Requirements Checklist
-
-| # | Requirement | Status | Explanation |
-|---|-------------|--------|-------------|
-| 1 | **Three-layer hierarchy** | **Partially supported** | Supports 2-layer hierarchy (entry point orchestrator → workers). The Orchestrator-Worker pattern has an orchestrator delegating to workers. A 3rd layer would require nesting agencies or custom coding — not natively supported. |
-| 2 | **Config-driven orchestrators** | **Not supported** | Agents and communication flows are defined in Python code. Instructions can be loaded from markdown files, but the orchestration structure itself is code-defined, not config-driven. |
-| 3 | **Parallel subagent execution** | **Supported** | Explicitly supported: "the CEO can assign tasks to both Developer and Virtual Assistant, so they will run in parallel in different threads and come back with their results to the CEO." [Source](https://agency-swarm.ai/core-framework/agencies/communication-flows) |
-| 4 | **Strict hierarchy communication** | **Supported** | Communication flows are directional tuples. By defining only parent→child flows and omitting peer flows, communication is restricted to parent-child only. |
-| 5 | **User-to-agent messaging mid-execution** | **Partially supported** | TUI supports `@mentions` to direct messages to specific agents. FastAPI supports `recipient_agent` parameter. However, mid-execution injection to running agents is not documented. |
-| 6 | **Conflict prevention** | **Not supported** | No built-in mechanism for assigning non-overlapping file scopes to parallel agents. |
-| 7 | **Role-scoped tooling** | **Supported** | Each agent has its own `tools=[]`, `tools_folder`, `schemas_folder`. Tools are role-specific. Shared tools can be applied via `shared_tools`. |
-| 8 | **Skills system (markdown instructions)** | **Partially supported** | Instructions can be loaded from markdown files (`instructions.md`). Shared instructions via `shared_instructions` (file path). Per-agent folder structure includes `tools/`, `files/`, `schemas/`. However, it's not a directory-based skills system with global + project level override. |
-| 9 | **LSP integration** | **Not supported** | No Language Server Protocol integration. |
-| 10 | **Shell access with permissions** | **Not supported** | No native shell execution tool. Relies on OpenAI function calling for tool execution. |
-| 11 | **Session management** | **Partially supported** | Thread persistence via `load_threads_callback`/`save_threads_callback`. `/cost` command in TUI. No chat forking or model switching mid-conversation documented. |
-| 12 | **Human-in-the-loop checkpoints** | **Partially supported** | Has input and output guardrails that can check/modify messages. No explicit configurable checkpoint mechanism for pausing execution. |
-| 13 | **State persistence** | **Partially supported** | Thread state can be persisted via callbacks. User context accessible by agents. No built-in plan/artifact persistence mechanism. |
-| 14 | **Provider-agnostic LLM** | **Supported** | Native OpenAI (GPT-5, GPT-4o). Via LiteLLM router: Anthropic, Google Gemini, Grok, Azure OpenAI, OpenRouter. |
-| 15 | **Multiple interfaces** | **Supported** | TUI (`agency.tui()`, `npx @vrsen/agentswarm`), CopilotKit/AG-UI web interface (`agency.copilot_demo()`), FastAPI HTTP API, async/sync programmatic API. |
-
----
-
-### 3. Microsoft Semantic Kernel
-
-#### Core Architecture
-
-Semantic Kernel is an **enterprise-grade SDK** for integrating LLMs into applications and building AI agents and multi-agent systems. It is written in C# (primary), Python, and Java. The kernel acts as a Dependency Injection container that manages AI services and plugins. The Agent Framework (within SK) provides agent types and orchestration patterns. **Important**: Semantic Kernel has been succeeded by **Microsoft Agent Framework** (MAF), which is the enterprise-ready successor at v1.0.
-
-- **Languages**: C# (66.8%), Python (31.3%), Java
-- **GitHub stars**: ~27.9k, 4,991 commits
-- **Latest release**: python-1.42.0 (May 14, 2026 — very active)
-- **Primary use case**: Building AI-powered applications with LLM integration, plugin extensibility, and multi-agent orchestration
-- **Python**: 3.10+, .NET: .NET 10.0+, Java: JDK 17+
-
-Key architectural components:
-- **Kernel**: Central DI container holding services (AI, logging, HTTP) and plugins
-- **Plugins**: Collections of functions with semantic descriptions, supporting native code, OpenAPI, and MCP
-- **Agent Framework**: `ChatCompletionAgent`, `OpenAIAssistantAgent`, `AzureAIAgent`, `OpenAIResponsesAgent`
-- **Orchestration Framework** (experimental): `ConcurrentOrchestration`, `SequentialOrchestration`, `HandoffOrchestration`, `GroupChatOrchestration`, `MagenticOrchestration`
-
-[Source: Semantic Kernel GitHub README](https://github.com/microsoft/semantic-kernel)
-[Source: SK Agent Framework Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/)
-[Source: SK Orchestration Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/)
-
-#### Requirements Checklist
-
-| # | Requirement | Status | Explanation |
-|---|-------------|--------|-------------|
-| 1 | **Three-layer hierarchy** | **Not supported** | Orchestration patterns support 2 layers (orchestrator ↔ agents). GroupChat supports collaborative patterns but not a strict 3-tier dispatch→orchestrator→subagent chain. The experimental orchestration framework is flat. |
-| 2 | **Config-driven orchestrators** | **Not supported** | Semantic Kernel is code-first SDK. Orchestration patterns are instantiated via code (e.g., `ConcurrentOrchestration(members=[...])`). Prompt templates can be loaded from files but orchestration structure is not config-driven. |
-| 3 | **Parallel subagent execution** | **Supported** | `ConcurrentOrchestration` runs multiple agents in parallel on the same task. Results are collected independently. [Source](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/concurrent) |
-| 4 | **Strict hierarchy communication** | **Not supported** | Agents in GroupChat/Orchestration all communicate via shared history. No mechanism to restrict communication to parent-child only. |
-| 5 | **User-to-agent messaging mid-execution** | **Partially supported** | Messages can be added to `ChatHistory` at any point, but not designed for injecting into a running orchestration mid-execution. |
-| 6 | **Conflict prevention** | **Not supported** | No built-in file scope assignment mechanism. |
-| 7 | **Role-scoped tooling** | **Supported** | Each agent can be configured with different plugins/function tools. Plugins can be per-agent using the kernel's DI system. |
-| 8 | **Skills system (markdown instructions)** | **Partially supported** | Has a powerful plugin system with semantic kernel functions. Prompt templates support variable substitution. Not a markdown/text-based skills directory system with global/project override. |
-| 9 | **LSP integration** | **Not supported** | No Language Server Protocol integration. |
-| 10 | **Shell access with permissions** | **Not supported** | No built-in shell execution tool. Code execution would need a custom plugin. |
-| 11 | **Session management** | **Not supported** | No built-in session management, chat forking, or model switching. ChatHistory is in-memory. |
-| 12 | **Human-in-the-loop checkpoints** | **Partially supported** | Human-in-the-loop is mentioned for "task automation functions" but there is no explicit configurable checkpoint mechanism in the agent framework. |
-| 13 | **State persistence** | **Not supported** | No built-in state persistence. ChatHistory, plans, artifacts are in-memory. Requires custom implementation for persistence. |
-| 14 | **Provider-agnostic LLM** | **Supported** | Built-in support for OpenAI, Azure OpenAI, Hugging Face, NVIDIA. Local deployment with Ollama, LMStudio, ONNX. |
-| 15 | **Multiple interfaces** | **Not supported** | SDK only. No CLI, TUI, or built-in API server. Must build your own interfaces. |
-
----
-
-## Comparison Summary Table
-
-| # | Requirement | SuperAGI | Agency Swarm | Semantic Kernel |
-|---|-------------|----------|--------------|-----------------|
-| 1 | Three-layer hierarchy | ❌ Not supported | ⚠️ Partial (2-layer) | ❌ Not supported |
-| 2 | Config-driven orchestrators | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 3 | Parallel subagent execution | ⚠️ Partial (independent runs) | ✅ Supported | ✅ Supported |
-| 4 | Strict hierarchy communication | ❌ Not supported | ✅ Supported | ❌ Not supported |
-| 5 | User-to-agent messaging mid-execution | ⚠️ Partial (Action Console) | ⚠️ Partial (@mentions, recipient_agent) | ⚠️ Partial (ChatHistory injection) |
-| 6 | Conflict prevention | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 7 | Role-scoped tooling | ⚠️ Partial (toolkit marketplace) | ✅ Supported | ✅ Supported |
-| 8 | Skills system (markdown instructions) | ⚠️ Partial (prompt templates) | ⚠️ Partial (instructions.md, shared_instructions) | ⚠️ Partial (plugin system) |
-| 9 | LSP integration | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 10 | Shell access with permissions | ❌ Not supported | ❌ Not supported | ❌ Not supported |
-| 11 | Session management | ⚠️ Partial (memory storage, telemetry) | ⚠️ Partial (thread callbacks) | ❌ Not supported |
-| 12 | Human-in-the-loop checkpoints | ⚠️ Partial (Action Console) | ⚠️ Partial (guardrails) | ⚠️ Partial (mentioned for plugins) |
-| 13 | State persistence | ⚠️ Partial (PostgreSQL, file storage) | ⚠️ Partial (thread callbacks, user context) | ❌ Not supported |
-| 14 | Provider-agnostic LLM | ✅ Supported | ✅ Supported | ✅ Supported |
-| 15 | Multiple interfaces | ✅ Supported (GUI, CLI, API) | ✅ Supported (TUI, Web, API, programmatic) | ❌ Not supported (SDK only) |
-
----
-
-## Key Questions Answered
-
-### 1. What is each framework's core architecture? How many layers of agent hierarchy does it support?
-
-- **SuperAGI**: Single autonomous agent architecture. No hierarchy — each agent is independent. Maximum layers: 1.
-- **Agency Swarm**: Multi-agent orchestration with directional communication flows. Supports 2 layers (entry point orchestrator → worker agents). Can be stretched by nesting but not natively supported.
-- **Semantic Kernel**: SDK for building AI agents with orchestration patterns. Supports 2 layers (orchestrator → participating agents). Orchestration framework is experimental.
-
-### 2. How extensible/configurable is each framework without modifying source code?
-
-- **SuperAGI**: System-level YAML config for API keys, models, DB. Tools via marketplace/JSON. Agent prompt templates with variable substitution. Limited config without modifying Python code.
-- **Agency Swarm**: Instructions from markdown files. Tools from folder discovery. MCP server support. Communication flows via code. No config-file-based agent definition.
-- **Semantic Kernel**: Plugin system via native code, OpenAPI specs, or MCP servers. Prompt templates. Everything requires code (C#, Python, Java).
-
-### 3. What is the primary use case each framework was designed for?
-
-- **SuperAGI**: Running individual autonomous AI agents with tool augmentation for task completion.
-- **Agency Swarm**: Multi-agent collaboration with structured organizational patterns (CEO, developer, virtual assistant roles).
-- **Semantic Kernel**: Integrating LLMs into enterprise .NET/Python applications with plugin extensibility and multi-agent orchestration.
-
-### 4. How active is each project?
-
-- **SuperAGI**: Stale. v0.0.11 release, README states "Under Development!" with 182 open issues. Community: Discord, Reddit. Creator active on Twitter.
-- **Agency Swarm**: Very active. v1.9.8 (May 6, 2026). 61 releases. 2,412 commits. Active maintainer (VRSEN). Discord community. YouTube channel.
-- **Semantic Kernel**: Very active. python-1.42.0 (May 14, 2026). 269 releases. 4,991 commits. Microsoft-maintained. Discord community. Note: now superseded by Microsoft Agent Framework.
-
-### 5. What language is each framework written in?
-
-- **SuperAGI**: Python
-- **Agency Swarm**: Python (primary), JavaScript/TypeScript (TUI UI)
-- **Semantic Kernel**: C# (primary, 66.8%), Python (31.3%), Java
-
----
-
-## Source list
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [SuperAGI GitHub Repository](https://github.com/TransformerOptimus/SuperAGI) | GitHub / code |
-| 2 | [SuperAGI config_template.yaml](https://raw.githubusercontent.com/TransformerOptimus/SuperAGI/main/config_template.yaml) | Config file |
-| 3 | [SuperAGI agent_prompt_builder.py](https://github.com/TransformerOptimus/SuperAGI/blob/main/superagi/agent/agent_prompt_builder.py) | Source code |
-| 4 | [Agency Swarm GitHub Repository](https://github.com/VRSEN/agency-swarm) | GitHub / code |
-| 5 | [Agency Swarm Docs - Overview](https://agency-swarm.ai/core-framework/agencies/overview) | Official docs |
-| 6 | [Agency Swarm Docs - Communication Flows](https://agency-swarm.ai/core-framework/agencies/communication-flows) | Official docs |
-| 7 | [Agency Swarm Docs - Running an Agency](https://agency-swarm.ai/core-framework/agencies/running-agency) | Official docs |
-| 8 | [Agency Swarm Docs - Agents Overview](https://agency-swarm.ai/core-framework/agents/overview) | Official docs |
-| 9 | [Agency Swarm Docs - Observability](https://agency-swarm.ai/additional-features/observability) | Official docs |
-| 10 | [Agency Swarm Docs - FastAPI Integration](https://agency-swarm.ai/additional-features/fastapi-integration) | Official docs |
-| 11 | [Semantic Kernel GitHub Repository](https://github.com/microsoft/semantic-kernel) | GitHub / code |
-| 12 | [SK Agent Framework Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/) | Official docs (Microsoft Learn) |
-| 13 | [SK Orchestration Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/) | Official docs (Microsoft Learn) |
-| 14 | [SK Concurrent Orchestration Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/concurrent) | Official docs (Microsoft Learn) |
-| 15 | [SK Kernel Concepts Docs](https://learn.microsoft.com/en-us/semantic-kernel/concepts/kernel) | Official docs (Microsoft Learn) |
-| 16 | [SK Plugins Docs](https://learn.microsoft.com/en-us/semantic-kernel/concepts/plugins/) | Official docs (Microsoft Learn) |
-
----
-
-## Verbatim quotes
-
-- "A dev-first open source autonomous AI agent framework enabling developers to build, manage & run useful autonomous agents. You can run concurrent agents seamlessly" — [SuperAGI GitHub README](https://github.com/TransformerOptimus/SuperAGI)
-- "The CEO can assign tasks to both Developer and Virtual Assistant, so they will run in parallel in different threads and come back with their results to the CEO" — [Agency Swarm Docs - Communication Flows](https://agency-swarm.ai/core-framework/agencies/communication-flows)
-- "Communication flows are defined using tuples in the communication_flows parameter: (sender, receiver) defines a directional communication path" — [Agency Swarm Docs - Communication Flows](https://agency-swarm.ai/core-framework/agencies/communication-flows)
-- "Concurrent orchestration enables multiple agents to work on the same task in parallel. Each agent processes the input independently, and their results are collected and aggregated" — [SK Concurrent Orchestration Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/concurrent)
-- "Semantic Kernel is now Microsoft Agent Framework! Microsoft Agent Framework (MAF) is the enterprise‑ready successor to Semantic Kernel" — [Semantic Kernel GitHub README](https://github.com/microsoft/semantic-kernel)
-- "Agent Orchestration features in the Agent Framework are in the experimental stage" — [SK Orchestration Docs](https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/)
-
----
-
-## Source quality flags
-
-- Source 3 (SuperAGI agent_prompt_builder.py): source code file, high quality for understanding architecture but shows framework is single-agent focused
-- Source 1 (SuperAGI README): marketing language — "dev-first", "build, manage & run useful autonomous agents" — but backed by code
-- Source 2 (SuperAGI config_template.yaml): primary source showing config capabilities
-- Sources 5-10 (Agency Swarm docs): official documentation, well-maintained, comprehensive
-- Sources 12-16 (SK Microsoft Learn): official Microsoft documentation, high quality
-- Source 11 (SK GitHub): official Microsoft repo, highly active
-
----
-
-## Confidence: High
-
-All findings are based on direct examination of official GitHub repositories, official documentation sites, source code files, and configuration files. No marketing or third-party summaries were relied upon.
-
----
-
-## Gaps and open questions
-
-1. **SuperAGI's current maintenance status**: The README says "Under Development!" and the last release tag appears to be v0.0.11, but the commit history shows recent activity. Confirming the exact current maintenance pace would require deeper investigation.
-2. **Agency Swarm's 3rd layer**: Whether nesting `Agency` instances or using custom code could achieve a 3-layer dispatch→orchestrator→subagent hierarchy is not documented. This would need experimental verification.
-3. **Semantic Kernel's successor relationship**: Microsoft Agent Framework (MAF) is now the recommended path. It's unclear how long Semantic Kernel will receive updates. MAF may address some gaps listed here.
-4. **None of the three frameworks support**: LSP integration, shell access with directory permissions, chat forking/model switching, or explicit conflict prevention. These would need custom implementation regardless of framework choice.
-5. **Config-driven orchestration**: None of the three frameworks support defining orchestrator types via config files (not code). This appears to be a gap unique to the current landscape of agent frameworks.
-
----
-
-## Tool calls made
-
-1. `webfetch` — https://github.com/TransformerOptimus/SuperAGI (GitHub README)
-2. `webfetch` — https://github.com/VRSEN/agency-swarm (GitHub README)
-3. `webfetch` — https://github.com/microsoft/semantic-kernel (GitHub README)
-4. `webfetch` — https://superagi.com/docs/ (failed — transport error)
-5. `webfetch` — https://agency-swarm.ai/core-framework/agencies/overview
-6. `webfetch` — https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/
-7. `webfetch` — https://agency-swarm.ai/core-framework/agencies/communication-flows
-8. `webfetch` — https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/
-9. `webfetch` — https://superagi.com/docs/architecture/ (failed — transport error)
-10. `webfetch` — https://github.com/TransformerOptimus/SuperAGI/blob/main/README.MD
-11. `webfetch` — https://agency-swarm.ai/additional-features/observability
-12. `webfetch` — https://learn.microsoft.com/en-us/semantic-kernel/frameworks/agent/agent-orchestration/concurrent
-13. `webfetch` — https://raw.githubusercontent.com/TransformerOptimus/SuperAGI/main/config_template.yaml
-14. `webfetch` — https://agency-swarm.ai/core-framework/agents/overview
-15. `webfetch` — https://agency-swarm.ai/core-framework/agencies/running-agency
-16. `webfetch` — https://github.com/TransformerOptimus/SuperAGI/blob/main/superagi/agent/agent_prompt_builder.py
-17. `webfetch` — https://learn.microsoft.com/en-us/semantic-kernel/concepts/kernel
-18. `webfetch` — https://agency-swarm.ai/additional-features/fastapi-integration
-19. `webfetch` — https://learn.microsoft.com/en-us/semantic-kernel/concepts/plugins/
-20. `webfetch` — https://raw.githubusercontent.com/microsoft/semantic-kernel/main/FEATURE_MATRIX.md
-21. `webfetch` — https://github.com/VRSEN/agency-swarm/blob/main/AGENTS.md
-22. `webfetch` — https://github.com/TransformerOptimus/SuperAGI/tree/main/superagi/agent
-23. `webfetch` — https://learn.microsoft.com/en-us/semantic-kernel/concepts/prompt-engineering/ (failed — 404)
diff --git a/research/multi-agent-orchestration.md b/research/multi-agent-orchestration.md
deleted file mode 100644
index 6c5c5f5..0000000
--- a/research/multi-agent-orchestration.md
+++ /dev/null
@@ -1,235 +0,0 @@
-# Subagent Report: Multi-Agent Orchestration Frameworks
-
-## Research summary
-This report evaluates three open-source multi-agent orchestration frameworks — **CrewAI**, **Microsoft AutoGen (AG2)**, and **LangGraph** — against a 15-point requirements checklist for an AI agent harness. CrewAI is the most opinionated with a built-in hierarchy and YAML-driven configuration, LangGraph is the lowest-level graph-based orchestration runtime with the most flexible persistence model, and AutoGen is a maintenance-mode framework with a layered design and no-code GUI. No framework fully satisfies all 15 requirements; each has gaps in the areas of LSP integration, shell access with directory permissions, and skills systems as defined.
-
----
-
-## Findings
-
-### 1. CrewAI
-
-#### Core Architecture
-
-CrewAI is an open-source Python framework for orchestrating role-based AI agents. It is built entirely from scratch — independent of LangChain. It provides two complementary paradigms: **Crews** (autonomous agent teams with role-based collaboration) and **Flows** (event-driven, stateful workflows). Version 1.14.5 (latest as of May 2026). Language: Python (99%+). Stars: ~51.7k on GitHub. Commits: ~2,414. Recent releases: 191 total, active.
-
-**Architecture layers:**
-- **Agent** — role, goal, backstory, tools, LLM config
-- **Crew** — orchestrates a team of agents with a Process (sequential, hierarchical, or hybrid)
-- **Flow** — higher-level orchestration with `@start`, `@listen`, `@router` decorators, state management, persistence
-- **Config** — YAML files for agents and tasks (recommended approach)
-
-**Primary use case:** General-purpose multi-agent automation for enterprise workflows. Not specifically focused on coding — more general business process automation.
-
-**Project status:** Very active. Backed by a company (CrewAI Inc.). 100k+ certified developers in the community.
-
-#### Requirements Checklist
-
-| # | Requirement | Status | Detail |
-|---|------------|--------|--------|
-| 1 | **Three-layer hierarchy** | **Partial** | CrewAI has a native hierarchical process where a manager agent coordinates sub-agents. However, it's a 2-level hierarchy (manager → agent), not 3-level (dispatch → orchestrator → subagent). Flows can chain multiple crews, achieving multi-level composition programmatically but not as a built-in dispatch architecture. |
-| 2 | **Config-driven orchestrators** | **Fully** | Agents and tasks are defined in YAML (`agents.yaml`, `tasks.yaml`) loaded via `@CrewBase` decorators. This is the recommended approach. Source: CrewAI docs "YAML Configuration (Recommended)" |
-| 3 | **Parallel subagent execution** | **Fully** | Tasks support `async_execution=True` for parallel execution. Multiple tasks can run concurrently when using the context mechanism for dependency management. Source: CrewAI Tasks docs on Asynchronous Execution |
-| 4 | **Strict hierarchy communication** | **Partial** | The hierarchical process assigns a manager that delegates tasks and validates results, providing structured parent-child communication. However, there is no built-in mechanism to prevent peer-to-peer agent messaging when delegation is enabled (`allow_delegation`). |
-| 5 | **User-to-agent messaging mid-execution** | **Partial** | CrewAI supports `@human_feedback` decorator (v1.8.0+) for human-in-the-loop in Flows, and `human_input=True` on tasks. However, this is for configured approval points, not arbitrary mid-execution injection to any running agent. |
-| 6 | **Conflict prevention** | **Not at all** | No built-in mechanism for assigning non-overlapping file scopes to parallel agents. Code execution is deprecated (uses external sandboxes like E2B). |
-| 7 | **Role-scoped tooling** | **Fully** | Agents can have different tool sets based on role. Tools are assigned per-agent via the `tools` parameter. Tasks can also override tools. Source: CrewAI Agents documentation on tools |
-| 8 | **Skills system** | **Partial** | Supports custom system templates, prompt templates, and response templates per agent. The project template uses `agents.yaml` for defining agent behaviors. Has a "skills" feature via MCP/skills.sh for AI coding assistants, but no directory-based markdown instruction system for agent definition. |
-| 9 | **LSP integration** | **Not at all** | No Language Server Protocol integration for compiler diagnostics. |
-| 10 | **Shell access with directory permissions** | **Not at all** | Code execution is deprecated in favor of external sandboxes. No shell access with permission controls. |
-| 11 | **Session management** | **Partial** | Flows support `@persist` decorator for state persistence across restarts using SQLite. Supports "fork" via `restore_from_state_id`. No chat forking or model switching mid-conversation in the traditional sense. |
-| 12 | **Human-in-the-loop checkpoints** | **Fully** | `@human_feedback` decorator on Flow methods pauses execution and collects feedback. `human_input=True` on tasks enables human review. Source: CrewAI Flows docs on human_feedback |
-| 13 | **State persistence** | **Fully** | `@persist` decorator on Flows persists state to SQLite automatically. Supports resume and fork patterns. Source: CrewAI Flows persistence docs |
-| 14 | **Provider-agnostic LLM** | **Fully** | Supports OpenAI, Azure, Anthropic, Ollama, Gemini, and many more via LiteLLM integration. The `llm` parameter accepts model strings or `LLM` instances. Source: CrewAI docs "Connecting Your Crew to a Model" |
-| 15 | **Multiple interfaces** | **Partial** | Has a CLI (`crewai create`, `crewai run`, `crewai flow kickoff`) and a Python API. No native TUI or API server in the open-source version (CrewAI AMP provides enterprise management console). |
-
----
-
-### 2. Microsoft AutoGen (AG2)
-
-#### Core Architecture
-
-AutoGen is a Python framework for building multi-agent AI applications. Developed by Microsoft Research. Currently in **maintenance mode** — no new features, community-managed only. Latest release: `python-v0.7.5` (Sep 2025). Stars: ~58.2k. Forks: ~8.8k. Commits: ~3,782. Microsoft recommends migrating to **Microsoft Agent Framework** for new projects.
-
-**Architecture layers (3-tier design):**
-- **Core API** — message passing, event-driven agents, distributed runtime, cross-language (Python + .NET)
-- **AgentChat API** — higher-level opinionated API for rapid prototyping with agents, teams, group chats
-- **Extensions API** — model clients, tools, code execution backends
-
-**Team patterns:** RoundRobinGroupChat, SelectorGroupChat, Swarm, MagenticOneGroupChat, GraphFlow
-
-**Primary use case:** Conversational multi-agent AI applications, research, prototyping. Magentic-One for web/file tasks.
-
-**Project status:** Maintenance mode. No new features. Users directed to Microsoft Agent Framework.
-
-#### Requirements Checklist
-
-| # | Requirement | Status | Detail |
-|---|------------|--------|--------|
-| 1 | **Three-layer hierarchy** | **Partial** | AutoGen has a flat agent model with "teams" orchestrating agents. It supports Swarm and SelectorGroupChat for multi-agent coordination, and GraphFlow for workflows. No native 3-layer dispatch → orchestrator → subagent hierarchy exists. Subordination must be implemented manually via `AgentTool` wrapping. |
-| 2 | **Config-driven orchestrators** | **Partial** | AutoGen Studio provides a no-code GUI for prototyping. The framework itself is code-first — teams, agents, and termination conditions are defined in Python code. Component serialization exists (`.dump_component()`) but is not a YAML-based config system. |
-| 3 | **Parallel subagent execution** | **Partial** | The `AgentTool` pattern allows one agent to call another as a tool, but this is sequential delegation, not parallel execution. RoundRobinGroupChat is sequential (turn-based). No native parallel agent execution within a team. |
-| 4 | **Strict hierarchy communication** | **Partial** | In SelectorGroupChat and Swarm, speaker selection is controlled. `AgentTool` wrapping creates a tool-call boundary. However, no strict parent-child communication restriction mechanism is built in. |
-| 5 | **User-to-agent messaging mid-execution** | **Fully** | `UserProxyAgent` allows injecting user input during team execution (blocking). `ExternalTermination` can stop teams mid-execution. `HandoffTermination` enables handoff to user. Source: AutoGen Human-in-the-Loop docs |
-| 6 | **Conflict prevention** | **Not at all** | No built-in mechanism for non-overlapping file scopes in parallel agents. |
-| 7 | **Role-scoped tooling** | **Fully** | Each `AssistantAgent` can have its own set of tools. Agent descriptions define their role for the selector. Source: AutoGen AgentChat docs on agents and tools |
-| 8 | **Skills system** | **Not at all** | No skills system for injecting markdown/text instructions per agent type. Agents are configured via `system_message` string and `description` string in code. |
-| 9 | **LSP integration** | **Not at all** | No Language Server Protocol integration. |
-| 10 | **Shell access with directory permissions** | **Not at all** | Code execution requires external sandboxes or MCP tools. No built-in shell access with permissions. |
-| 11 | **Session management** | **Partial** | Teams support `save_state()` and `load_state()` for persisting conversation state. State can be serialized to JSON. No chat forking or model switching mid-conversation. |
-| 12 | **Human-in-the-loop checkpoints** | **Fully** | `UserProxyAgent` for inline feedback, `HandoffTermination` for async feedback, `max_turns` for turn-based pausing. Source: AutoGen Human-in-the-Loop tutorial |
-| 13 | **State persistence** | **Fully** | `save_state()` / `load_state()` on agents and teams. State dictionaries can be serialized to file or database. Source: AutoGen Managing State docs |
-| 14 | **Provider-agnostic LLM** | **Fully** | Supports OpenAI, Azure OpenAI, Azure AI Foundry, Anthropic (experimental), Ollama (experimental), Gemini (via API), Llama API, plus Semantic Kernel adapter for even more providers. Source: AutoGen Models docs |
-| 15 | **Multiple interfaces** | **Fully** | Python API, CLI (`autogenstudio ui`), AutoGen Studio (no-code GUI web app), and FastAPI/ChainLit/Streamlit integration samples. Source: AutoGen README and FastAPI sample |
-
----
-
-### 3. LangGraph
-
-#### Core Architecture
-
-LangGraph is a low-level orchestration framework for building stateful, long-running agents. Developed by LangChain Inc. Built as a graph-based runtime inspired by Google's Pregel and Apache Beam. Latest release: `langgraph==1.2.0` (May 2026). Stars: ~32.4k. Commits: ~6,862. Active development (534 releases total).
-
-**Architecture:**
-- **StateGraph** — defines state schema (TypedDict/Pydantic), nodes (functions), edges (conditional/static)
-- **Subgraphs** — graphs used as nodes in other graphs (supports multi-agent patterns)
-- **Checkpointer** — persistence layer for state snapshots at every super-step
-- **Store** — cross-thread memory for long-term knowledge
-- **Persistence modes:** per-invocation (default), per-thread, stateless
-
-**Primary use case:** Low-level agent orchestration for complex, stateful, long-running workflows. Used by Klarna, Replit, Elastic, Uber, J.P. Morgan. Higher-level abstraction available via Deep Agents and LangChain agents.
-
-**Project status:** Very active. Backed by LangChain Inc with commercial LangSmith platform.
-
-#### Requirements Checklist
-
-| # | Requirement | Status | Detail |
-|---|------------|--------|--------|
-| 1 | **Three-layer hierarchy** | **Fully** | LangGraph's subgraph architecture supports arbitrary nesting. A parent graph can contain subgraphs, which can contain further subgraphs. `Command(goto=..., graph=Command.PARENT)` enables navigation between levels. Each level has its own state schema. Source: LangGraph Subgraphs documentation |
-| 2 | **Config-driven orchestrators** | **Not at all** | LangGraph is purely code-defined — graphs, nodes, edges, and state schemas are all Python code. No YAML or config file support for defining orchestrator types. LangSmith Studio provides a UI but generates code. |
-| 3 | **Parallel subagent execution** | **Fully** | Multiple outgoing edges from a single node execute in parallel (same super-step). `Send()` API enables map-reduce patterns with dynamic fan-out. Subgraphs can run in parallel. Source: LangGraph Graph API docs on edges and Send |
-| 4 | **Strict hierarchy communication** | **Fully** | Subgraphs can have private state schemas invisible to the parent graph. When a subgraph is invoked via a node function, the parent only sees what the node function returns. State isolation is achieved via separate state schemas. Source: LangGraph Subgraphs docs on different state schemas |
-| 5 | **User-to-agent messaging mid-execution** | **Fully** | `interrupt()` function pauses graph execution and returns control to the caller. The caller can inspect state and resume with `Command(resume=...)`. Supports multiple simultaneous interrupts. Source: LangGraph Interrupts documentation |
-| 6 | **Conflict prevention** | **Not at all** | No built-in mechanism for file scope conflict prevention. |
-| 7 | **Role-scoped tooling** | **Fully** | Each agent node can have its own set of tools. In multi-agent patterns, subgraphs/agents have independent tool configurations. Tools are LangChain-compatible. |
-| 8 | **Skills system** | **Not at all** | No skills system for injecting markdown instructions per agent type. No directory-based instruction organization. Agents use system prompts defined in code. |
-| 9 | **LSP integration** | **Not at all** | No Language Server Protocol integration. |
-| 10 | **Shell access with directory permissions** | **Not at all** | No built-in shell access. Code execution relies on external tools or LangChain tool integrations. |
-| 11 | **Session management** | **Partial** | Checkpointer-based threads provide conversation history via `get_state_history()`. Time-travel debugging via replay from checkpoints. `update_state()` for editing state. No explicit chat forking or model switching mid-conversation. |
-| 12 | **Human-in-the-loop checkpoints** | **Fully** | `interrupt()` function for dynamic pausing. Static breakpoints via `interrupt_before`/`interrupt_after` at compile time. Supports approval workflows, review-and-edit, and validation loops. Source: LangGraph Interrupts documentation |
-| 13 | **State persistence** | **Fully** | Multiple checkpointers: InMemorySaver, SqliteSaver, PostgresSaver, Azure CosmosDB. Checkpoints at every super-step. Cross-thread memory via Store. Encryption support. Source: LangGraph Persistence documentation |
-| 14 | **Provider-agnostic LLM** | **Fully** | LangGraph can use any LangChain-compatible model provider (OpenAI, Anthropic, Google, Ollama, AWS Bedrock, Azure, etc.) plus standalone models without LangChain. The `Runtime` context can pass model configuration. |
-| 15 | **Multiple interfaces** | **Partial** | Python API primarily. LangSmith Studio for visual prototyping. LangGraph API for deployment. No native CLI or TUI. Deep Agents SDK provides a higher-level interface. |
-
----
-
-## Summary Comparison Table
-
-| # | Requirement | CrewAI | AutoGen (AG2) | LangGraph |
-|---|------------|--------|---------------|-----------|
-| 1 | **Three-layer hierarchy** | Partial (2-level natively, chaining via Flows) | Partial (flat agent teams, Swarm/GraphFlow) | **Fully** (arbitrary nesting via subgraphs) |
-| 2 | **Config-driven orchestrators** | **Fully** (YAML agents.yaml/tasks.yaml) | Partial (code-first, Studio GUI, component serialization) | Not at all (purely code-defined) |
-| 3 | **Parallel subagent execution** | **Fully** (async_execution tasks) | Partial (sequential team patterns, AgentTool is blocking) | **Fully** (Send API, parallel edges, map-reduce) |
-| 4 | **Strict hierarchy communication** | Partial (manager delegates but no P2P prevention) | Partial (SelectorGroupChat controls turns, AgentTool boundary) | **Fully** (private state schemas, subgraph isolation) |
-| 5 | **User-to-agent messaging mid-execution** | Partial (@human_feedback at configured points) | **Fully** (UserProxyAgent, ExternalTermination, HandoffTermination) | **Fully** (interrupt()/Command(resume=...) anywhere) |
-| 6 | **Conflict prevention** | Not at all | Not at all | Not at all |
-| 7 | **Role-scoped tooling** | **Fully** (per-agent tools, task override) | **Fully** (per-agent tools) | **Fully** (per-node tools, LangChain-compatible) |
-| 8 | **Skills system** | Partial (agent templates, prompt customization) | Not at all | Not at all |
-| 9 | **LSP integration** | Not at all | Not at all | Not at all |
-| 10 | **Shell access with directory permissions** | Not at all | Not at all | Not at all |
-| 11 | **Session management** | Partial (Flow persist/fork) | Partial (save_state/load_state, JSON serialization) | Partial (checkpointer history, time travel, update_state) |
-| 12 | **Human-in-the-loop checkpoints** | **Fully** (@human_feedback, human_input on tasks) | **Fully** (UserProxyAgent, HandoffTermination, max_turns) | **Fully** (interrupt(), static breakpoints, approval patterns) |
-| 13 | **State persistence** | **Fully** (@persist with SQLite, resume/fork) | **Fully** (save_state/load_state to file or DB) | **Fully** (checkpointers: Memory, SQLite, Postgres, CosmosDB) |
-| 14 | **Provider-agnostic LLM** | **Fully** (many providers via LiteLLM) | **Fully** (many providers via Extensions API + SK adapter) | **Fully** (all LangChain providers, plus standalone mode) |
-| 15 | **Multiple interfaces** | Partial (CLI + Python API, AMP enterprise console) | **Fully** (Python API, CLI, Studio web GUI, FastAPI/Streamlit) | Partial (Python API, LangSmith Studio, LangGraph API) |
-
----
-
-## Overall Assessment
-
-### CrewAI
-**Strength:** Most opinionated framework for role-based agents with YAML-driven configuration. Excellent for enterprise automation where you want to define agents declaratively. Strong community (100k+ certified developers), active development, and a commercial offering (CrewAI AMP).
-
-**Key Gaps for this harness:** No LSP, no shell permissions, no 3-layer dispatch hierarchy natively, limited mid-execution user injection.
-
-### AutoGen (AG2)
-**Strength:** Best GUI/studio support for prototyping. Most flexible human-in-the-loop with `UserProxyAgent` and handoff patterns. Multiple interface options (Python, CLI, Studio web app). Provider-agnostic model support with Semantic Kernel adapter.
-
-**Key Gaps:** **Maintenance mode** — no new features, users directed to Microsoft Agent Framework. No parallel execution, no config-driven setup, flat agent model.
-
-### LangGraph
-**Strength:** Most architecturally flexible — arbitrary graph topologies, arbitrary subgraph nesting, the richest persistence model (multiple checkpointers + cross-thread store), durable execution, and the most sophisticated interrupt system. Best for complex, long-running, stateful workflows.
-
-**Key Gaps:** Code-only configuration (no YAML), no built-in skills system, no CLI/TUI, steeper learning curve due to low-level nature. No file scope conflict prevention.
-
----
-
-## Source list
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [CrewAI GitHub Repository](https://github.com/crewAIInc/crewAI) | official |
-| 2 | [CrewAI Documentation - Agents](https://docs.crewai.com/concepts/agents) | official |
-| 3 | [CrewAI Documentation - Tasks](https://docs.crewai.com/concepts/tasks) | official |
-| 4 | [CrewAI Documentation - Flows](https://docs.crewai.com/concepts/flows) | official |
-| 5 | [CrewAI Documentation - Memory](https://docs.crewai.com/concepts/memory) | official |
-| 6 | [CrewAI Documentation - Processes](https://docs.crewai.com/core-concepts/Processes/) | official |
-| 7 | [AutoGen GitHub Repository](https://github.com/microsoft/autogen) | official |
-| 8 | [AutoGen Documentation - Teams](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/teams.html) | official |
-| 9 | [AutoGen Documentation - Human-in-the-Loop](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/human-in-the-loop.html) | official |
-| 10 | [AutoGen Documentation - Managing State](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/state.html) | official |
-| 11 | [AutoGen Documentation - Models](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/models.html) | official |
-| 12 | [AutoGen Documentation - Selector Group Chat](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/selector-group-chat.html) | official |
-| 13 | [LangGraph GitHub Repository](https://github.com/langchain-ai/langgraph) | official |
-| 14 | [LangGraph Documentation - Overview](https://docs.langchain.com/oss/python/langgraph/overview) | official |
-| 15 | [LangGraph Documentation - Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api) | official |
-| 16 | [LangGraph Documentation - Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs) | official |
-| 17 | [LangGraph Documentation - Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts) | official |
-| 18 | [LangGraph Documentation - Persistence](https://docs.langchain.com/oss/python/langgraph/persistence) | official |
-
----
-
-## Verbatim quotes
-
-- "CrewAI is a lean, lightning-fast Python framework built entirely from scratch—completely independent of LangChain or other agent frameworks." — [CrewAI GitHub README](https://github.com/crewAIInc/crewAI)
-- "Using YAML configuration provides a cleaner, more maintainable way to define agents. We strongly recommend using this approach in your CrewAI projects." — [CrewAI Agents docs](https://docs.crewai.com/concepts/agents)
-- "AutoGen is now in maintenance mode. It will not receive new features or enhancements and is community managed going forward." — [AutoGen GitHub README](https://github.com/microsoft/autogen)
-- "New users should start with Microsoft Agent Framework. Existing users are encouraged to migrate." — [AutoGen GitHub README](https://github.com/microsoft/autogen)
-- "LangGraph is a low-level orchestration framework for building, managing, and deploying long-running, stateful agents." — [LangGraph GitHub README](https://github.com/langchain-ai/langgraph)
-- "Subgraphs are useful for building multi-agent systems, reusing a set of nodes in multiple graphs, and distributing development." — [LangGraph Subgraphs docs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs)
-- "Interrupts allow you to pause graph execution at specific points and wait for external input before continuing." — [LangGraph Interrupts docs](https://docs.langchain.com/oss/python/langgraph/interrupts)
-- "UserProxyAgent is a special built-in agent that acts as a proxy for a user to provide feedback to the team." — [AutoGen HITL docs](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/human-in-the-loop.html)
-
----
-
-## Source quality flags
-
-- No significant quality issues found. All three sources are primary (official GitHub repositories, official documentation sites).
-- AutoGen's documentation is comprehensive but the project is in maintenance mode.
-
----
-
-## Confidence: High
-
-All claims are sourced from official GitHub repositories and official documentation sites for each framework (CrewAI, AutoGen, LangGraph). The information reflects the current state as of May 2026.
-
-## Gaps and open questions
-
-- **LSP integration**: None of the three frameworks support Language Server Protocol integration. This would need to be built as a custom extension for any chosen framework.
-- **Shell access with directory permissions**: None support this natively. Shell access would require wrapping shell tools with permission checks manually in any framework.
-- **Skills system**: Only CrewAI has anything close (prompt templates, agent configuration files), but none have the directory-based markdown instruction system described in the requirements.
-- **Conflict prevention for file scopes**: No framework has built-in mechanisms for this. Would need to be implemented as a custom tool wrapper or middleware.
-- **Session management (chat forking, model switching)**: All three have basic state persistence but not the full session management features (chat forking, model switching mid-conversation).
-- **CrewAI's Allow Delegation**: When `allow_delegation=True`, agents can delegate tasks to other agents, which could include peer agents — creating implicit P2P communication. The strictness of hierarchy enforcement depends on configuration.
-- **AutoGen's successor**: Microsoft Agent Framework (MAF) is the recommended successor and may address many gaps, but it was outside this research scope.
-
----
-
-## Tool calls made
-
-1. `webfetch` - 3 GitHub README pages (CrewAI, AutoGen, LangGraph)
-2. `webfetch` - CrewAI Processes page, Crews page
-3. `webfetch` - LangGraph overview, AutoGen tutorial, CrewAI agents, tasks
-4. `webfetch` - CrewAI flows, AutoGen HITL, LangGraph interrupts, LangGraph subgraphs (404)
-5. `webfetch` - LangGraph persistence, AutoGen state management, AutoGen selector chat, LangGraph graph API
-6. `webfetch` - LangGraph subgraphs, CrewAI memory, AutoGen models
diff --git a/research/multi-agent-roleplay.md b/research/multi-agent-roleplay.md
deleted file mode 100644
index 72686a7..0000000
--- a/research/multi-agent-roleplay.md
+++ /dev/null
@@ -1,439 +0,0 @@
-# Subagent Report: Multi-Agent Role-Playing Frameworks (MetaGPT, ChatDev, CAMEL)
-
-## Research summary
-
-This report evaluates three open-source multi-agent simulation/role-playing frameworks — **MetaGPT**, **ChatDev**, and **CAMEL** — against 15 specific AI agent harness requirements. MetaGPT is the most mature for software-development workflows (68.1k stars) but has a flat role-based architecture, not a deep hierarchy. ChatDev 2.0 (released Jan 2026) has pivoted to a zero-code YAML-driven DAG orchestration platform with strong config-driven design and parallel execution. CAMEL is the most general-purpose research framework (17k stars, 210+ releases) with the broadest model provider support and excellent tooling abstractions, but lacks strict hierarchy enforcement and production-oriented features like session management or shell permissions. None of the three frameworks fully satisfy all 15 requirements; each has distinct gaps in hierarchy depth, user-to-agent messaging, LSP integration, and shell permission controls.
-
----
-
-## Findings
-
-### 1. MetaGPT
-
-#### Core Architecture
-
-MetaGPT is a Python-based multi-agent framework designed to simulate a software company. Its core philosophy is `Code = SOP(Team)` — Standard Operating Procedures materialized into a team of LLM-based agents. The architecture has three main abstractions:
-
-- **Role**: An agent with specific actions, memory, and watch/observe patterns
-- **Action**: A discrete unit of work (LLM-powered or code-only)
-- **Team**: A collection of roles operating within an Environment
-
-Agents communicate by publishing messages to the Environment, which other agents observe. The communication model is a publish-subscribe pattern (agents publish to the environment, others observe based on watched Action types).
-
-**Key metrics**: 68.1k stars, 8.7k forks, 6,367 commits, Python 3.9+ (Python 97.5%). Latest release v0.8.1 (April 2024). The project has been relatively quiet in terms of releases since April 2024, though the repo continues to receive commits. The team has shifted focus to MGX ([mgx.dev](https://mgx.dev/)) — a commercial natural language programming product.
-
-[Source: GitHub README](https://github.com/geekan/MetaGPT)
-
-#### Hierarchy Depth
-
-MetaGPT supports a **flat role pool** within a single Team/Environment. Agents are peers — there is no orchestrator/subagent distinction. The `Team` class manages a set of roles, but it does not support recursive nesting (a sub-team within a team). Communication is broadcast-based: all messages go to the Environment, and roles subscribe to specific message types via `_watch()`. There is no built-in three-layer hierarchy.
-
-[Source: MultiAgent 101](https://docs.deepwisdom.ai/main/en/guide/tutorials/multi_agent_101.html)
-
-#### Config System
-
-MetaGPT has a YAML config file (`~/.metagpt/config2.yaml`) that supports:
-- LLM provider settings (api_type, base_url, api_key, model)
-- Per-role LLM overrides
-- Search, browser, Redis, S3 configurations
-- Experience pool and memory settings
-
-However, orchestrator types, agent behaviors, and workflow logic are **defined in Python code**, not in configuration files. The config covers infrastructure and LLM settings but not orchestration logic.
-
-[Source: config2.example.yaml](https://github.com/geekan/MetaGPT/blob/main/config/config2.example.yaml)
-
-#### Parallel Execution
-
-MetaGPT's `Team` executes roles **sequentially in a round-robin fashion**. The `n_round` parameter controls how many rounds of interaction occur. There is no built-in parallel subagent execution — agents take turns within each round. The async implementation (`asyncio`) exists in the codebase but is used for single-agent action execution, not parallel multi-agent execution.
-
-[Source: MultiAgent 101](https://docs.deepwisdom.ai/main/en/guide/tutorials/multi_agent_101.html)
-
-#### Communication Restrictions
-
-MetaGPT uses a **broadcast-based** communication model. When a Role publishes a message, it goes to all agents via the Environment. There is a `send_to` field on messages, but the default sends to `["<all>"]`. There is no built-in mechanism to enforce that subagents only talk to their parent orchestrator — all agents observe all messages.
-
-[Source: Agent communication](https://docs.deepwisdom.ai/main/en/guide/in_depth_guides/agent_communication.html)
-
-#### User-to-Agent Messaging Mid-Execution
-
-MetaGPT supports **human engagement** via setting `is_human=True` on a Role. This causes the terminal to prompt for user input when it's that role's turn. However, this is a **replacement** of an agent role with a human, not arbitrary message injection to any running agent at any time. The human takes over a specific role in the SOP.
-
-[Source: Human Engagement](https://docs.deepwisdom.ai/main/en/guide/tutorials/human_engagement.html)
-
-#### Conflict Prevention (File Scopes)
-
-MetaGPT has **no built-in mechanism** for assigning non-overlapping file scopes to parallel agents. The generated code is written to a shared workspace directory. There is no file-locking, scope assignment, or conflict detection.
-
-#### Role-Scoped Tooling
-
-MetaGPT supports **per-role tool assignment** through the `Role` class and its `set_actions()` method. Each role can be equipped with different actions (tools). The config file supports per-role LLM configuration. However, tool configuration is done in Python code, not via config files.
-
-[Source: Agent 101](https://docs.deepwisdom.ai/main/en/guide/tutorials/agent_101.html)
-
-#### Skills System
-
-MetaGPT does **not** have a directory-based skills system with markdown/text instructions per agent type. It has an "experience pool" (`exp_pool`) feature that stores past experiences, but this is not a skills directory system with global + project-level organization.
-
-[Source: config2.example.yaml](https://github.com/geekan/MetaGPT/blob/main/config/config2.example.yaml)
-
-#### LSP Integration
-
-MetaGPT has **no LSP integration**. Generated code is written to disk, but there is no built-in Language Server Protocol client for compiler diagnostics or code validation.
-
-#### Shell Access with Directory Permissions
-
-MetaGPT's `SimpleRunCode` action executes Python code via `subprocess.run()`. There is **no permission control system** — no auto-allow lists, no prompt-for-out-of-scope directories. Code execution is unrestricted within the subprocess.
-
-[Source: Agent 101 - SimpleRunCode](https://docs.deepwisdom.ai/main/en/guide/tutorials/agent_101.html)
-
-#### Session Management
-
-MetaGPT supports **serialization and breakpoint recovery** via `--recover_path`. It serializes team state (roles, memories, actions) to a JSON file and can resume execution. However, there is **no support for chat forking, model switching mid-conversation, or loading/resuming old chats** as separate sessions.
-
-[Source: Serialization & Breakpoint Recovery](https://docs.deepwisdom.ai/main/en/guide/in_depth_guides/breakpoint_recovery.html)
-
-#### Human-in-the-Loop Checkpoints
-
-MetaGPT has **no configurable checkpoint system** for pausing execution at predefined points. The human engagement feature (`is_human=True`) pauses at each role turn, but this replaces the agent rather than providing a checkpoint/approval mechanism. There are no approval gates or pause/resume hooks.
-
-#### State Persistence
-
-Yes — MetaGPT serializes team, environment, roles, and actions to JSON files in a `./workspace/storage` directory. This enables recovery after crashes or Ctrl-C. The serialization captures memory, role state, action progress, and environment history.
-
-[Source: Serialization & Breakpoint Recovery](https://docs.deepwisdom.ai/main/en/guide/in_depth_guides/breakpoint_recovery.html)
-
-#### Provider-Agnostic LLM
-
-Yes — MetaGPT supports multiple LLM providers via a configurable `api_type` field. Options include OpenAI, Azure, Ollama, Groq, Gemini, and others. Each role can have a different LLM provider.
-
-[Source: Config example](https://github.com/geekan/MetaGPT/blob/main/config/config2.example.yaml)
-
-#### Multiple Interfaces
-
-MetaGPT supports:
-- **CLI**: `metagpt "Create a 2048 game"` command
-- **Python library**: Import and use as a Python package
-- No dedicated TUI or API mode
-
-[Source: README](https://github.com/geekan/MetaGPT)
-
----
-
-### 2. ChatDev (2.0 / DevAll)
-
-#### Core Architecture
-
-ChatDev has undergone a major transformation. **ChatDev 2.0 (DevAll)** — released Jan 7, 2026 — is a **zero-code multi-agent orchestration platform**. It is no longer focused solely on software development but positions itself as a platform for "Developing Everything" through configurable DAG-based workflows.
-
-The architecture is:
-- **YAML-defined workflows** describing DAGs of agent nodes
-- **Node types**: `agent` (LLM), `python`, `human`, `subgraph`, `passthrough`, `literal`, `loop_counter`, `loop_timer`
-- **Web UI** (Vue 3) for visual workflow design + execution dashboard
-- **Python SDK / PyPI package** (`chatdev`) for programmatic execution
-- **FastAPI backend** for the server component
-
-**Key metrics**: 33.1k stars, 4.1k forks, Python 68.7% / Vue 28.5%. Latest release v2.2.0 (March 23, 2026). Very active development — 190 commits on main branch for 2.0. The legacy ChatDev 1.x is preserved on the `chatdev1.0` branch.
-
-[Source: ChatDev README](https://github.com/OpenBMB/ChatDev)
-
-#### Hierarchy Depth
-
-ChatDev 2.0 supports **multi-level nesting** through its `subgraph` node type. A workflow can reference another workflow file or inline graph, enabling recursive hierarchy. Additionally, its "Tree Mode" (in dynamic execution) provides a natural fan-out/reduce pattern. The DAG structure with start/end nodes enables clear orchestration flows. However, this is a **directed acyclic graph** structure, not a strict three-level dispatch->orchestrator->subagent tree. The hierarchy is defined by edge topology, not by enforced levels.
-
-[Source: Workflow Authoring Guide](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Config-Driven Orchestrators
-
-**Yes** — this is ChatDev 2.0's core strength. Entire workflows, including agent types, models, prompts, tools, edge conditions, and execution logic, are defined in YAML configuration files under `yaml_instance/`. No Python code changes are needed to create new orchestrator types. The `DesignConfig` structure with `version`, `vars`, and `graph` keys provides a clean configuration schema.
-
-[Source: Workflow Authoring Guide](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Parallel Subagent Execution
-
-**Yes** — ChatDev 2.0 supports parallel execution through its **dynamic execution** feature. Both **Map Mode** (`type: map`, fan-out) and **Tree Mode** (`type: tree`, fan-out with recursive reduce) are supported. The `max_parallel` parameter controls concurrency limits. This enables true parallel subagent execution.
-
-[Source: Workflow Authoring Guide - Dynamic Execution](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Strict Hierarchy Communication
-
-ChatDev 2.0's DAG-based execution **enforces communication along defined edges**. Nodes only receive messages from their upstream nodes and send to downstream nodes. However, there is no parent-child trust boundary — all nodes within a workflow are at the same trust level. The `subgraph` node type provides some isolation (child graph has its own nodes), but there is no mechanism to enforce that sub-nodes can _only_ communicate with their parent orchestrator.
-
-**Partial support** — edges define communication paths, but strict hierarchical communication with parent-only routing is not enforced.
-
-[Source: Workflow Authoring Guide - Edges](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### User-to-Agent Messaging Mid-Execution
-
-ChatDev 2.0 supports this via the **`human` node type**. When execution reaches a `human` node, the workflow pauses and waits for user input in the Web UI. However, messaging is constrained to the node level — the user interacts at predefined points in the DAG, not with arbitrary running agents at any time. The `human` node must be explicitly placed in the workflow DAG.
-
-**Partial support** — user can inject messages at designated human nodes, but not to any arbitrary agent mid-execution.
-
-[Source: Workflow Authoring Guide - Node Types](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Conflict Prevention
-
-ChatDev 2.0 has **no built-in conflict prevention** mechanism for non-overlapping file scopes. The system does not assign or track file write scopes. The `python` node type executes scripts sharing a `code_workspace/` directory, but there is no file-locking or scope isolation.
-
-#### Role-Scoped Tooling
-
-**Yes** — the `AgentConfig.tooling` field lets different agent nodes have different tool sets. Tools are configured per-node in the YAML workflow. The MCP (Model Context Protocol) is also supported for tool integration. Tooling is defined at the node level, providing role-scoped access.
-
-[Source: Workflow Authoring Guide - Agent Node Advanced Features](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Skills System
-
-ChatDev 2.0 has a **`.agents/skills`** directory with a directory-based skill organization. Currently contains `greeting-demo`, `python-scratchpad`, and `rest-api-caller` skills. The skills system is designed for injectable markdown/text instructions per agent type, with both global and project-level organization. However, the documentation does not detail how skills are loaded/scoped to specific agent nodes.
-
-[Source: .agents/skills directory](https://github.com/OpenBMB/ChatDev/tree/main/.agents/skills)
-
-#### LSP Integration
-
-ChatDev has **no LSP integration** documented. The platform focuses on workflow orchestration and multi-agent collaboration, not on providing compiler diagnostics or language server features.
-
-#### Shell Access with Directory Permissions
-
-ChatDev 2.0 has **no documented shell permission controls**. The `python` node type executes scripts, and Docker support is available for safe execution (noted in legacy 1.x), but there is no auto-allow list, prompt-for-scope, or directory-based permission system in the current documentation.
-
-#### Session Management
-
-ChatDev 2.0 supports **session management** through the Web UI — workflows can be launched, monitored, and inspected. Context snapshots are saved to `WareHouse/<session>/context.json`. There is HTTP API support (`POST /api/workflow/execute`) and CLI execution (`python run.py`). However, there is **no support for chat forking, model switching mid-conversation, or loading/resuming old chats** in the documented features.
-
-[Source: Workflow Authoring Guide - CLI/API Execution Paths](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Human-in-the-Loop Checkpoints
-
-**Yes** — the `human` node type provides explicit checkpoints where the workflow pauses for user input. Conditions on edges (e.g., `keyword` condition checking for "ACCEPT") can create approval gates. The workflow only continues when the user provides the expected input.
-
-[Source: Workflow Authoring Guide - Conditions](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### State Persistence
-
-ChatDev 2.0 saves workflow session data to `WareHouse/` directories, including context snapshots. The SQLite database (`sync` command) stores workflow definitions. However, there is **no documented mechanism for full state persistence across restarts** that would allow resuming a workflow from where it left off after a crash (like MetaGPT's breakpoint recovery).
-
-**Partial support** — artifacts and logs persist, but workflow execution state recovery is not documented.
-
-[Source: Workflow Authoring Guide - Debugging Tips](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Provider-Agnostic LLM
-
-**Yes** — ChatDev 2.0 supports multiple LLM providers configured per-node via `provider` field (e.g., `openai`, `gemini`, etc.). The config uses `${VAR}` syntax for API keys and base URLs. The `globals.default_provider` sets a fallback. Providers can be mixed within a single workflow.
-
-[Source: Workflow Authoring Guide - Providers](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md)
-
-#### Multiple Interfaces
-
-ChatDev 2.0 supports:
-- **Web UI / TUI**: Vue 3 frontend at port 5173 with visual workflow canvas and execution dashboard
-- **CLI**: `python run.py --path yaml_instance/demo.yaml --name test_run`
-- **HTTP API**: `POST /api/workflow/execute` with session management
-- **Python SDK**: `chatdev` PyPI package for programmatic execution
-
-[Source: ChatDev README](https://github.com/OpenBMB/ChatDev)
-
----
-
-### 3. CAMEL
-
-#### Core Architecture
-
-CAMEL is a Python-based research framework focused on "finding the scaling laws of agents." It is the most general-purpose and academically oriented of the three frameworks. The architecture includes:
-
-- **ChatAgent**: Core agent abstraction with LLM, tools, and memory
-- **Agent Societies**: Coordination layers including `RolePlaying`, `BabyAGI`, and `Workforce`
-- **Workforce**: A hierarchical agent orchestration system with task decomposition and worker assignment
-- **ModelFactory**: Abstract LLM interface supporting 45+ model platforms
-- **Toolkits**: 70+ tool integrations (search, browser, code execution, MCP, etc.)
-- **Memory & Storage**: Persistent state management
-- **Interpreters**: Code/command execution backends
-- **Human-in-the-Loop**: Tool approval and interactive components
-
-**Key metrics**: 17k stars, 1.9k forks, 2,208 commits, Python 95.9% / TypeScript 2.1%. Latest release v0.2.90 (March 22, 2026). Extremely active — 210 releases and very frequent commits. Large community with Discord, WeChat, and Reddit presence. Backed by Eigen AI research collective with 100+ researchers.
-
-[Source: CAMEL README](https://github.com/camel-ai/camel)
-
-#### Hierarchy Depth
-
-CAMEL's **Workforce** module provides a hierarchical multi-agent system. The `Workforce` class acts as an orchestrator that receives a task, decomposes it into subtasks, assigns workers (single agents or role-playing pairs), and aggregates results. The workforce supports **multiple levels** — a worker can itself be a `Workforce` (recursive nesting), enabling arbitrary hierarchy depth. The `RolePlaying` society creates two-agent task-solving pairs. However, workforce configuration is done via Python code, not config files, and the hierarchy is defined programmatically.
-
-[Source: CAMEL societies/workforce](https://github.com/camel-ai/camel/tree/master/camel/societies/workforce)
-
-#### Config-Driven Orchestrators
-
-CAMEL is **primarily Python-code-driven**. The `ModelFactory.create()` method accepts programmatic configuration. While `create_from_yaml()` and `create_from_json()` methods exist for model configuration, the orchestration logic (workforce setup, task decomposition, worker assignment) is **defined in Python code**. There is no YAML-based workflow definition system like ChatDev 2.0.
-
-**Partial support** — model config can be loaded from YAML/JSON files, but orchestration logic cannot.
-
-[Source: model_factory.py](https://github.com/camel-ai/camel/blob/master/camel/models/model_factory.py)
-
-#### Parallel Subagent Execution
-
-CAMEL's Workforce **does not appear to have built-in parallel execution** of subagents. The workforce processes tasks sequentially through its worker pool. While individual agents can make async LLM calls, the workforce orchestration itself is not designed for parallel fan-out execution. The documentation emphasizes scalability to "millions of agents" as a design principle but does not describe current parallel execution in the workforce implementation.
-
-**Not supported** in the current documented and released architecture.
-
-[Source: CAMEL societies/workforce](https://github.com/camel-ai/camel/tree/master/camel/societies/workforce)
-
-#### Strict Hierarchy Communication
-
-CAMEL's Workforce uses a **centralized orchestration** pattern — the workforce assigns tasks to workers and collects results. Workers do not communicate directly with each other; they receive tasks from and return results to the workforce. The `RolePlaying` society, however, involves direct agent-to-agent communication. The framework does not explicitly enforce a "subagents only talk to parent" policy — the architecture enables this pattern through the workforce abstraction by design, but there is no system-level enforcement mechanism.
-
-**Partial support** — Workforce architecture naturally restricts communication to parent-child, but no formal enforcement mechanism exists.
-
-[Source: CAMEL Workforce codebase](https://github.com/camel-ai/camel/tree/master/camel/societies/workforce)
-
-#### User-to-Agent Messaging Mid-Execution
-
-CAMEL has a **Human toolkit** (`human_toolkit.py`) and documented **Human-in-the-Loop** features including tool approval. The framework supports interactive components for human oversight and intervention. However, these are primarily **tool approval** mechanisms (approving tool calls before execution), not free-form message injection into any running agent at any time. The messaging pattern requires explicit setup in code.
-
-**Partial support** — human-in-the-loop tool approval exists, but mid-execution arbitrary message injection is not a built-in feature.
-
-[Source: CAMEL README - Human-in-the-Loop](https://github.com/camel-ai/camel)
-
-#### Conflict Prevention
-
-CAMEL has **no built-in conflict prevention** mechanism for non-overlapping file scopes. The framework provides code execution via `Interpreters` and toolkits like `code_execution.py`, but there is no file-locking, scope assignment, or conflict detection for parallel agents writing to the same filesystem.
-
-#### Role-Scoped Tooling
-
-**Yes** — CAMEL has an extensive toolkit system with **70+ toolkits** (search, browser, code execution, GitHub, Gmail, Slack, MCP, etc.). Tools are assigned to individual agents at creation time via the `tools` parameter of `ChatAgent`. Different agents can have completely different tool sets. The skill system also provides role-scoped capabilities.
-
-[Source: CAMEL toolkits directory](https://github.com/camel-ai/camel/tree/master/camel/toolkits)
-
-#### Skills System
-
-CAMEL has a **`.camel/skills`** directory with a directory-based skill organization. Currently contains `docs-incremental-update` and `skill-creator` skills. The `skill_toolkit.py` provides programmatic access to skills. Skills can be loaded and assigned to agents. However, the system does not appear to support both global and project-level skill directories — the skills are in a single project-level `.camel/skills` directory.
-
-[Source: .camel/skills directory](https://github.com/camel-ai/camel/tree/master/.camel/skills)
-
-#### LSP Integration
-
-CAMEL has **no LSP integration** documented. The framework focuses on multi-agent research and task automation, not on providing compiler diagnostics or language server features.
-
-#### Shell Access with Directory Permissions
-
-CAMEL provides shell access through its `TerminalToolkit` (`terminal_toolkit/`), `CodeExecution` toolkit, and `Interpreters`. However, there is **no documented permission control system** — no auto-allow lists, no prompt-for-out-of-scope directories, no directory-based sandboxing.
-
-[Source: CAMEL toolkits - terminal_toolkit](https://github.com/camel-ai/camel/tree/master/camel/toolkits/terminal_toolkit)
-
-#### Session Management
-
-CAMEL supports **session and conversation management** through its `Memory` module and `Storage` module, which provide persistent context layers for chat history and tool outputs. The framework logs model request/response to JSON files when `CAMEL_MODEL_LOG_ENABLED=true`. However, there is **no support for chat forking, model switching mid-conversation, or loading/resuming old chats** as a built-in feature.
-
-[Source: CAMEL README - Model Logging](https://github.com/camel-ai/camel)
-
-#### Human-in-the-Loop Checkpoints
-
-CAMEL supports **Human-in-the-Loop** features including tool approval (approving tool calls before execution) and interactive components. The `human_toolkit.py` provides human interaction capabilities. However, there is **no checkpoint system** for pausing execution at configurable predefined points — the human involvement is primarily about tool-call approval, not workflow-level pause/resume.
-
-**Partial support** — tool approval exists, but configurable execution checkpoints do not.
-
-[Source: CAMEL README - Human-in-the-Loop](https://github.com/camel-ai/camel)
-
-#### State Persistence
-
-CAMEL provides **stateful memory** as a core design principle. Agents maintain stateful memory enabling multi-step interactions. The `Memory` and `Storage` modules provide persistent context. However, there is **no documented mechanism for full workflow/execution state persistence across restarts** (like MetaGPT's breakpoint recovery). The logging system captures request/response data, but this is not a recovery mechanism.
-
-**Partial support** — agent memory persists within a session, but full execution state recovery across restarts is not documented.
-
-[Source: CAMEL README - Statefulness](https://github.com/camel-ai/camel)
-
-#### Provider-Agnostic LLM
-
-**Yes** — this is CAMEL's strongest area. The `ModelFactory` supports **45+ model platforms** including OpenAI, Azure, Anthropic, Gemini, Mistral, Cohere, Ollama, vLLM, SGLang, Groq, DeepSeek, Qwen, and many more. The `ModelFactory.create()` method provides a clean abstract interface, and models can also be created from YAML/JSON config files via `create_from_yaml()` and `create_from_json()`.
-
-[Source: model_factory.py](https://github.com/camel-ai/camel/blob/master/camel/models/model_factory.py)
-
-#### Multiple Interfaces
-
-CAMEL supports:
-- **Python library**: Primary interface as a Python package (`pip install camel-ai`)
-- **CLI**: Via environment variable configuration and example scripts
-- **API/Server**: Apps directory contains server applications (FastAPI-based)
-- No dedicated TUI or web UI (unlike ChatDev's Vue 3 frontend)
-
-[Source: CAMEL README](https://github.com/camel-ai/camel)
-
----
-
-## Comparison Table
-
-| Requirement | MetaGPT | ChatDev 2.0 | CAMEL |
-|---|---|---|---|
-| **1. Three-layer hierarchy** | Not supported (flat role pool) | Partial (DAG + subgraph nesting) | Partial (recursive Workforce, code-configured) |
-| **2. Config-driven orchestrators** | Partial (infra config only, logic in code) | **Yes** (full YAML workflow definitions) | Partial (model config from YAML, logic in code) |
-| **3. Parallel subagent execution** | Not supported (sequential rounds) | **Yes** (Map/Tree modes, max_parallel) | Not supported (sequential workforce) |
-| **4. Strict hierarchy communication** | Not supported (broadcast) | Partial (edge-routed but no parent-only enforcement) | Partial (Workforce centralizes but no enforcement) |
-| **5. User-to-agent messaging mid-execution** | Partial (is_human role replacement) | Partial (human nodes in DAG) | Partial (tool approval, not free injection) |
-| **6. Conflict prevention** | Not supported | Not supported | Not supported |
-| **7. Role-scoped tooling** | **Yes** (per-role actions in code) | **Yes** (per-node tooling in YAML) | **Yes** (per-agent toolkits) |
-| **8. Skills system (markdown dirs)** | Not supported | Partial (.agents/skills dir exists) | Partial (.camel/skills dir exists) |
-| **9. LSP integration** | Not supported | Not supported | Not supported |
-| **10. Shell access with permissions** | Not supported (unrestricted subprocess) | Not supported (Docker for safety only) | Not supported (no permission system) |
-| **11. Session management** | Partial (breakpoint recovery) | Partial (context snapshots) | Partial (memory storage) |
-| **12. Human-in-the-loop checkpoints** | Not supported | **Yes** (human nodes + edge conditions) | Partial (tool approval only) |
-| **13. State persistence** | **Yes** (JSON serialization + recovery) | Partial (artifacts persist, no recovery) | Partial (memory/storage, no recovery) |
-| **14. Provider-agnostic LLM** | **Yes** (multiple providers via config) | **Yes** (per-node provider config) | **Yes** (45+ platforms, broadest support) |
-| **15. Multiple interfaces** | Partial (CLI + Python lib) | **Yes** (Web UI, CLI, HTTP API, Python SDK) | Partial (Python lib + server apps) |
-
----
-
-## Source list
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [MetaGPT GitHub](https://github.com/geekan/MetaGPT) | GitHub |
-| 2 | [MetaGPT Documentation - Concepts](https://docs.deepwisdom.ai/main/en/guide/tutorials/concepts.html) | Official docs |
-| 3 | [MetaGPT Documentation - Agent 101](https://docs.deepwisdom.ai/main/en/guide/tutorials/agent_101.html) | Official docs |
-| 4 | [MetaGPT Documentation - MultiAgent 101](https://docs.deepwisdom.ai/main/en/guide/tutorials/multi_agent_101.html) | Official docs |
-| 5 | [MetaGPT Documentation - Human Engagement](https://docs.deepwisdom.ai/main/en/guide/tutorials/human_engagement.html) | Official docs |
-| 6 | [MetaGPT Documentation - Breakpoint Recovery](https://docs.deepwisdom.ai/main/en/guide/in_depth_guides/breakpoint_recovery.html) | Official docs |
-| 7 | [MetaGPT config2.example.yaml](https://github.com/geekan/MetaGPT/blob/main/config/config2.example.yaml) | GitHub source |
-| 8 | [ChatDev GitHub](https://github.com/OpenBMB/ChatDev) | GitHub |
-| 9 | [ChatDev Workflow Authoring Guide](https://github.com/OpenBMB/ChatDev/blob/main/docs/user_guide/en/workflow_authoring.md) | Official docs |
-| 10 | [ChatDev .agents/skills](https://github.com/OpenBMB/ChatDev/tree/main/.agents/skills) | GitHub source |
-| 11 | [CAMEL GitHub](https://github.com/camel-ai/camel) | GitHub |
-| 12 | [CAMEL model_factory.py](https://github.com/camel-ai/camel/blob/master/camel/models/model_factory.py) | GitHub source |
-| 13 | [CAMEL societies/workforce](https://github.com/camel-ai/camel/tree/master/camel/societies/workforce) | GitHub source |
-| 14 | [CAMEL .camel/skills](https://github.com/camel-ai/camel/tree/master/.camel/skills) | GitHub source |
-| 15 | [CAMEL toolkits directory](https://github.com/camel-ai/camel/tree/master/camel/toolkits) | GitHub source |
-| 16 | [CAMEL Documentation](https://docs.camel-ai.org/) | Official docs |
-
----
-
-## Verbatim quotes
-
-- "MetaGPT takes a one line requirement as input and outputs user stories / competitive analysis / requirements / data structures / APIs / documents" — [Source 1](https://github.com/geekan/MetaGPT)
-- "Code = SOP(Team) is the core philosophy. We materialize SOP and apply it to teams composed of LLMs." — [Source 1](https://github.com/geekan/MetaGPT)
-- "ChatDev has evolved from a specialized software development multi-agent system into a comprehensive multi-agent orchestration platform." — [Source 8](https://github.com/OpenBMB/ChatDev)
-- "ChatDev 2.0 (DevAll) is a Zero-Code Multi-Agent Platform for 'Developing Everything'. It empowers users to rapidly build and customize multi-agent systems through simple configuration. No coding is required." — [Source 8](https://github.com/OpenBMB/ChatDev)
-- "CAMEL is an open-source community dedicated to finding the scaling laws of agents." — [Source 11](https://github.com/camel-ai/camel)
-- "The framework enables multi-agent systems to continuously evolve by generating data and interacting with environments." — [Source 11](https://github.com/camel-ai/camel) (Evolvability principle)
-- "The framework is designed to support systems with millions of agents, ensuring efficient coordination, communication, and resource management at scale." — [Source 11](https://github.com/camel-ai/camel) (Scalability principle)
-
----
-
-## Source quality flags
-
-- Source 5 (MetaGPT Human Engagement): mentions that the current interaction is "through terminal input, which is inconvenient for multi-line or structured writeup" — the feature is acknowledged as limited by maintainers
-- Source 8 (ChatDev GitHub): marketing language in the README — "World's first AI agent development team", "Zero-Code Multi-Agent Platform" — these are product positioning claims
-- Source 11 (CAMEL GitHub): marketing language — "the first and the best multi-agent framework" — this is self-promotion, not an objective claim
-
----
-
-## Confidence: Medium
-
-The information is drawn directly from GitHub repositories, official documentation, and source code. Confidence is medium because some features (especially those related to hierarchy depth, parallel execution, and session management) required interpretation of architecture and code structure rather than explicit documentation. ChatDev 2.0 is very new (released Jan 2026) and its documentation is still evolving, so some features may be present but undocumented.
-
----
-
-## Gaps and open questions
-
-1. **Hierarchy depth**: None of the three frameworks explicitly implements a three-layer dispatch->orchestrator->subagent architecture. ChatDev's subgraph nesting comes closest but is DAG-based, not tree-based. CAMEL's recursive Workforce is close but configured in code. A follow-up investigation should assess whether any framework could be extended to support this pattern without major refactoring.
-2. **Conflict prevention**: No framework addresses file-scope conflict prevention. This would likely require custom implementation regardless of framework choice.
-3. **LSP integration**: No framework has LSP support. This would be a novel feature to add.
-4. **Shell permissions**: No framework has directory-based shell permission controls. This is a significant gap for any production deployment that needs sandboxing.
-5. **MetaGPT release cadence**: The latest release (v0.8.1) is from April 2024 — over 13 months old. The project may be entering maintenance mode as the team focuses on MGX. This should be verified.
-6. **ChatDev 2.0 maturity**: ChatDev 2.0 was released in January 2026 and the documentation is still being written. Several module documentation pages (e.g., skills, tooling) are marked as "Chinese for now" or return 404. The framework's long-term stability is unproven.
-7. **CAMEL's parallel execution**: The workforce module's parallel execution capabilities need deeper investigation. The design principles mention scalability to "millions of agents" but current implementation appears sequential.
-
-
diff --git a/research/pi-dev-harness.md b/research/pi-dev-harness.md
deleted file mode 100644
index 8fb94b9..0000000
--- a/research/pi-dev-harness.md
+++ /dev/null
@@ -1,322 +0,0 @@
-# Subagent Report: pi.dev (Pi) — Super-Customizable AI Agent Harness
-
-## Research summary
-
-Pi (pi.dev / `@earendil-works/pi-coding-agent`) is a minimal, aggressively **extensible** terminal-based coding agent harness built in **TypeScript** by Mario Zechner (Earendil Inc.). It is **not** a multi-agent orchestration framework — it is a **single-agent CLI** (like Claude Code, opencode, or Codex) designed to be customized via TypeScript extensions, markdown skills, prompt templates, and themes. It has excellent built-in support for provider-agnostic LLMs, session management (tree-structured with branching), state persistence, user-to-agent messaging mid-execution, and multiple interfaces (TUI, CLI, RPC, SDK). However, **multi-agent hierarchy, config-driven orchestrators, LSP integration, conflict prevention, and role-scoped tooling are all absent by design** — the project philosophy is "primitives, not features." These would need to be built from scratch as extensions. Confidence: **high** — the source, docs, and blog posts are extensive and transparent about what Pi does and does not include.
-
----
-
-## Findings
-
-### 1. Overview: What Pi Is and Is Not
-
-**What exactly IS Pi?** Pi is a monorepo containing five npm packages:
-
-| Package | Purpose |
-|---|---|
-| `@earendil-works/pi-ai` | Unified multi-provider LLM API (15+ providers: Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, Cerebras, xAI, Hugging Face, OpenRouter, etc.) |
-| `@earendil-works/pi-agent-core` | Agent runtime with tool calling, state management, event streaming, compaction |
-| `@earendil-works/pi-coding-agent` | The main CLI: interactive TUI, print mode, JSON mode, RPC mode, SDK |
-| `@earendil-works/pi-tui` | Terminal UI library with differential rendering |
-| `@earendil-works/pi-web-ui` | Web components for AI chat interfaces |
-
-Pi is **not** a multi-agent orchestration framework like LangGraph or CrewAI. It is a **single-agent coding assistant CLI** that you run in a terminal. The "super customizable" claim refers to its extension system — you can add tools, commands, UI components, and behaviors via TypeScript extensions without forking the codebase.
-
-[Source: GitHub monorepo README](https://github.com/earendil-works/pi)
-
-**Language & Tech Stack:**
-
-- **TypeScript** (96.5% of the repo), JavaScript (2.9%), CSS (0.4%), Shell (0.2%)
-- Runtime: Node.js >= 22.19.0
-- Key dependencies: `jiti` (TypeScript loader for extensions), `typebox` (schema validation), `chalk`, `yaml`, `diff`, `undici`, `cross-spawn`
-- Packaging: npm ecosystem, published as `@earendil-works/pi-coding-agent`
-- Custom-built TUI framework (`pi-tui`) — not React/Ink-based
-
-[Source: package.json](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/package.json)
-
-**License:** MIT — fully open source.
-
-[Source: LICENSE file](https://github.com/earendil-works/pi/blob/main/LICENSE)
-
-**Activity & Community:** Extremely active.
-
-| Metric | Value |
-|---|---|
-| GitHub Stars | **51.4k** |
-| Forks | **6.1k** |
-| Commits | **4,188** |
-| Releases | **219** (latest: v0.75.3, May 18, 2026) |
-| Open Issues | 28 |
-| Open PRs | 6 |
-| Discord Community | Yes (linked from site) |
-| Maintainer | Mario Zechner (badlogicgames) / Earendil Inc. |
-
-Note: New contributor issues/PRs are auto-closed by default; maintainers review them daily.
-
-[Source: GitHub repo](https://github.com/earendil-works/pi)
-
-**Philosophy (from the maintainer's blog):**
-> "If I don't need it, it won't be built. And I don't need a lot of things."
-> "Pi is aggressively extensible so it doesn't have to dictate your workflow."
-> "Features that other tools bake in can be built with extensions, skills, or installed from third-party pi packages."
-
-Pi deliberately ships **without**: sub-agents, plan mode, permission popups, MCP support, background bash, and to-do lists. The maintainer believes these should be built as extensions or handled externally (tmux, containers, file-based plans).
-
-[Source: maintainer blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-
----
-
-### 2. Architecture Deep-Dive
-
-#### Core Architecture (Layered)
-
-```
-┌────────────────────────────────────────────────┐
-│ pi-coding-agent (CLI/TUI) │
-│ Session management, extensions, skills, │
-│ themes, prompt templates, commands, UI │
-├────────────────────────────────────────────────┤
-│ pi-agent-core │
-│ Agent loop, tool execution, state management │
-│ Event streaming, compaction, transport layer │
-├────────────────────────────────────────────────┤
-│ pi-ai │
-│ Unified LLM API: OpenAI, Anthropic, Google, │
-│ and 12+ more providers. Tool calling, │
-│ streaming, thinking/reasoning, context handoff │
-├────────────────────────────────────────────────┤
-│ pi-tui / pi-web-ui │
-│ Terminal UI framework / Web components │
-└────────────────────────────────────────────────┘
-```
-
-[Source: GitHub README](https://github.com/earendil-works/pi)
-
-#### Agent Creation & Management
-
-Pi's agent model is **single-agent per session**. Key classes:
-
-- **`AgentSession`** — Manages one agent's lifecycle, message history, model state, streaming. Created via `createAgentSession()`.
-- **`Agent`** (from `@earendil-works/pi-agent-core`) — The core loop: processes user messages, executes tool calls, feeds results back to LLM, repeats until done.
-- **`AgentSessionRuntime`** — Wraps `AgentSession` with session replacement capabilities (new/resume/fork/clone).
-- **SessionManager** — Handles persistence as tree-structured JSONL files.
-
-The agent loop is minimal: no max-steps limit, no sub-agent spawning. It loops until the model produces a non-tool-call response.
-
-#### Extension System (Plugin Model)
-
-Extensions are **TypeScript modules** auto-discovered from well-known directories:
-
-| Location | Scope |
-|---|---|
-| `~/.pi/agent/extensions/*.ts` | Global (all projects) |
-| `~/.pi/agent/extensions/*/index.ts` | Global (subdirectory) |
-| `.pi/extensions/*.ts` | Project-local |
-| `.pi/extensions/*/index.ts` | Project-local (subdirectory) |
-
-Extensions export a default factory function receiving `ExtensionAPI`:
-
-```typescript
-import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
-
-export default function (pi: ExtensionAPI) {
- // Register tools
- pi.registerTool({ name: "my_tool", ... });
-
- // Register commands
- pi.registerCommand("mycmd", { handler: async (args, ctx) => { ... } });
-
- // Register keyboard shortcuts
- pi.registerShortcut("ctrl+x", { handler: async (ctx) => { ... } });
-
- // Register CLI flags
- pi.registerFlag("my-flag", { description: "..." });
-
- // Hook events
- pi.on("tool_call", async (event, ctx) => { ... });
- pi.on("session_start", async (event, ctx) => { ... });
- pi.on("before_agent_start", async (event, ctx) => { ... });
- // ... 30+ event types
-}
-```
-
-**What extensions can do:**
-- Custom tools (or replace built-in tools entirely)
-- Intercept/block/modify tool calls via `tool_call` event
-- Custom compaction and summarization
-- Permission gates, path protection
-- Custom UI components, editors, status bars, overlays
-- SSH and sandbox execution
-- MCP server integration (via custom tool wrapping)
-- Sub-agents (spawn child `pi` processes via bash)
-- Session persistence via `pi.appendEntry()`
-- Custom providers and models
-
-Extensions are loaded via `jiti` (TypeScript compilation on-the-fly). Async factories are supported. Pi packages bundle extensions, skills, prompts, and themes for sharing via npm or git.
-
-**50+ extension examples** in the repo, including: subagent, plan-mode, permission-gate, protected-paths, sandbox, ssh, custom-compaction, snake game, Doom.
-
-[Source: Extensions documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md)
-
-#### Skills System
-
-Pi implements the [Agent Skills standard](https://agentskills.io). Skills are markdown files with YAML frontmatter stored in directories:
-
-```
-~/.pi/agent/skills/my-skill/SKILL.md
-~/.agents/skills/
-.pi/skills/
-.agents/skills/ (in cwd and ancestor directories)
-```
-
-Skills follow progressive disclosure: only descriptions are always in the context; the full SKILL.md content loads on-demand when triggered via `/skill:name` command or when the agent decides to read it.
-
-#### Event Lifecycle
-
-Pi exposes ~30 events across the lifecycle:
-- **Resource events**: `resources_discover`
-- **Session events**: `session_start`, `session_before_switch`, `session_before_fork`, `session_before_compact`, `session_compact`, `session_shutdown`
-- **Agent events**: `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end`, `message_start/update/end`
-- **Tool events**: `tool_call` (can block), `tool_result` (can modify), `tool_execution_start/update/end`
-- **Model events**: `model_select`, `thinking_level_select`
-- **Input events**: `input` (can intercept/transform)
-- **User bash events**: `user_bash`
-
-[Source: Extensions documentation (event reference)](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md)
-
----
-
-### 3. Requirements Checklist Evaluation
-
-For each requirement, two ratings are given:
-- **Built-in Support**: Is it present in the core today?
-- **Ease of Adding**: How hard to build on top of Pi's extension system?
-
-| # | Requirement | Built-in Support | Ease of Adding | Assessment |
-|---|---|---|---|---|
-| 1 | **Three-layer hierarchy** (dispatch→orchestrator→subagent) | **Not supported** — Pi is a single-agent system. No parent-child agent relationships. | **Hard** — No architectural concept of agent hierarchy exists. The subagent extension spawns child `pi` processes via bash, but has no orchestration layer, routing, or lifecycle management. Building a full 3-layer hierarchy with dispatch and orchestrator would require creating the entire system from scratch as an extension. | The architecture is flat: one agent session, one agent loop. Adding hierarchy means building a new abstraction layer atop the existing single-agent runtime, not extending an existing one. |
-| 2 | **Config-driven orchestrators** (orchestrator types defined via YAML/JSON config) | **Not supported** — No concept of "orchestrator types" or orchestration configs. | **Hard** — Pi uses JSON files for its own settings (settings.json, models.json), but there is no orchestrator abstraction. Would need to build a config schema and runtime interpreter for orchestrator definitions from scratch. | Pi's `settings.json` is for Pi configuration, not agent orchestration. A new config format and execution engine would be needed. |
-| 3 | **Parallel subagent execution** | **Not supported** built-in. The subagent example extension supports parallel execution (up to 8 tasks, 4 concurrent). | **Moderate** — The subagent extension already exists as a working example and supports parallel `pi` process spawning. However, it spawns child processes via bash (not in-process agents), has a 50KB output cap per task, and limited concurrency. Would need significant enhancement for production use. | Example exists but is a demo, not production-grade. Architecture of spawning subprocesses works but has limitations. |
-| 4 | **Strict hierarchy communication** (subagents only talk to parent orchestrator, no peer-to-peer) | **Not supported** — No communication framework between agents. Subagents (when used) are independent processes. | **Moderate** — Could build a message-passing protocol on top of the subagent extension or RPC mode. But Pi has no built-in mechanism for parent-child routing or peer-to-peer blocking. | Would need to implement a communication protocol and enforce routing rules. |
-| 5 | **User-to-agent messaging mid-execution** | **Supported** — Built-in message queuing. `Enter` queues a "steer" message (delivered after current tool call, interrupts remaining tools). `Alt+Enter` queues a "follow-up" (delivered when agent finishes). Escape aborts and restores queued messages. | N/A (already built-in) | **Strong feature.** Configurable delivery modes: `one-at-a-time` (default) or `all`. Also available programmatically via `session.steer()` and `session.followUp()` methods in the SDK. |
-| 6 | **Conflict prevention** (non-overlapping file scopes for parallel agents) | **Not supported** — Pi runs in YOLO mode with full filesystem access. No concept of file scope assignment. | **Hard** — Goes against Pi's core philosophy of unrestricted access. Could be approximated by running each agent in a different directory/container, but there's no built-in file-scope assignment or enforcement mechanism. | Pi's design philosophy explicitly rejects this kind of restriction. Working around it would be fighting the architecture. |
-| 7 | **Role-scoped tooling** (different agents get different tool sets based on role) | **Partial** — Pi's `--tools` flag can restrict which tools are available globally. Extensions can register custom tools. But there's no role-based assignment system. | **Moderate** — Could use the `before_agent_start` event to dynamically modify the toolset based on context. But there's no built-in concept of "agent roles" or tool-to-role mapping. | Single-agent system means no role differentiation. Would need to build role management into whatever multi-agent layer you add. |
-| 8 | **Skills system** (injectable markdown instructions per agent type, with specific directory structure) | **Partial** — Pi has a full skills system following the Agent Skills standard. Skills are markdown files with YAML frontmatter. They are discovered from `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, and ancestor `.agents/skills/` directories. However, the directory structure does NOT match the required `default/`, `agents/`, `project/` subdirectory scheme. | **Easy** — The skills system is mature and extensible. The directory structure is configurable through the `DefaultResourceLoader`. Adding support for additional directory conventions would be straightforward. | Skills system is one of Pi's strongest extensibility points. The directory convention difference is minor. |
-| 9 | **LSP integration** (Language Server Protocol for compiler/linter diagnostics) | **Not supported** — No LSP client. Pi has no compiler, linter, or language server integration. | **Hard** — Would need to build an LSP client as an extension, handling stdio JSON-RPC protocol, file synchronization, diagnostics display, etc. No existing LSP primitives exist in the codebase. | This is a significant feature to build, but not architecturally impossible — just no existing support. |
-| 10 | **Shell access with directory permissions** (auto-allow lists, prompt for out-of-scope directories) | **Not supported** — Pi has full unrestricted bash access by design ("YOLO mode"). There IS a `permission-gate.ts` extension example that prompts before dangerous commands, and a `protected-paths.ts` extension example. | **Moderate** — The tool_call event can intercept bash commands. Directory awareness could be added. But the permission model would need to be built from scratch (auto-allow lists, scope checking). | The extension event model makes this possible, but Pi's philosophy is deliberately against permission systems. |
-| 11 | **Session management** (chat forking, model switching mid-conversation, loading/resuming old chats) | **Supported** — Excellent built-in session management: tree-structured JSONL files, `/tree` navigation to any previous point, `/fork` (new session from user message), `/clone` (duplicate branch), `/resume` (pick from past sessions), `/model` to switch models mid-session, HTML export, share via GitHub gist. Auto-save on every message. | N/A (already built-in) | **Strong feature.** Sessions persist to `~/.pi/agent/sessions/` organized by working directory. Continue with `pi -c`. |
-| 12 | **Human-in-the-loop checkpoints** (execution pauses at configurable points for user approval) | **Partial** — No built-in checkpoint system. But the extension event model allows blocking on any tool_call event. The permission-gate extension demonstrates this pattern. | **Easy** — Extensions can block tool execution via `{ block: true, reason: "..." }` return from `tool_call` event handler. Can show confirmation dialogs via `ctx.ui.confirm()`. Configurable checkpoint logic can be implemented entirely in an extension. | The `tool_call` event's blocking capability is exactly designed for this. |
-| 13 | **State persistence** (sessions, plans, artifacts persist across restarts) | **Supported** — Sessions auto-save to disk as JSONL files. Everything persists: full message history, model state, compaction metadata, tool results. `pi -c` continues the most recent session. Sessions survive process restarts. | N/A (already built-in) | **Strong feature.** Tree structure with branching means no information is lost — old branches remain accessible via `/tree`. |
-| 14 | **Provider-agnostic LLM** (multiple providers through abstract interface) | **Supported** — 15+ built-in providers (Anthropic, OpenAI, Google, Azure, Bedrock, Mistral, Groq, Cerebras, xAI, Hugging Face, OpenRouter, Together AI, Fireworks, DeepSeek, Kimi, MiniMax, etc.), custom providers via `models.json`, custom providers via extensions with full OAuth flows. Model switching mid-session. | N/A (already built-in) | **Strong feature.** Cross-provider context handoff is built into `@earendil-works/pi-ai` — models can be switched mid-conversation. |
-| 15 | **Multiple interfaces** (CLI, TUI, API modes) | **Supported** — 4 built-in modes: **Interactive** (full TUI), **Print** (`-p` for scripts), **JSON** (`--mode json` for structured output), **RPC** (`--mode rpc` for stdin/stdout JSONL protocol). Plus an **SDK** for embedding Pi in Node.js apps. Plus a **web-ui** package for web interfaces. | N/A (already built-in) | **Strong feature.** The SDK exports `InteractiveMode`, `runPrintMode`, and `runRpcMode` utilities for building custom interfaces on top. |
-
----
-
-### 4. Strengths and Weaknesses as a Base for the Dispatch Harness
-
-#### Strengths
-
-1. **Exceptional session management** — Tree-structured sessions with branching, forking, cloning, resume, and model switching mid-conversation. This provides a robust foundation for state persistence.
-
-2. **Excellent provider-agnostic LLM layer** — 15+ providers, cross-provider context handoff, and a clean abstraction (`@earendil-works/pi-ai`). This could directly power the LLM layer of a dispatch system.
-
-3. **Powerful extension system** — TypeScript extensions with full access to lifecycle events, tool registration, UI components, and state management. The `tool_call` event's blocking capability is ideal for human-in-the-loop checkpoints. The `before_agent_start` event allows dynamic system prompt modification. These are the primitives needed for orchestration logic.
-
-4. **Message queuing mid-execution** — Built-in steer/follow-up messaging provides the foundation for user-to-agent communication during execution.
-
-5. **Multiple interface modes** — TUI, CLI, JSON, RPC, and SDK mean the system can be used interactively, programmatically, or embedded.
-
-6. **Mature skills system** — Follows the Agent Skills standard, with progressive disclosure. Easily adaptable to different directory conventions.
-
-7. **MIT license** — No restrictions on use or modification.
-
-8. **Active community and maintenance** — 219 releases, very active development. The project is not abandoned.
-
-#### Weaknesses
-
-1. **No multi-agent architecture** — Pi is fundamentally single-agent. There is no concept of agent hierarchy, orchestrator, dispatch, or subagent management. Building a 3-layer hierarchy (dispatch→orchestrator→subagent) means creating an entirely new architectural layer on top of Pi, not extending an existing one. This is the single biggest gap.
-
-2. **No config-driven orchestration** — Pi has no YAML/JSON-based orchestrator definitions, no workflow DSL, no task routing. This would need to be built from scratch.
-
-3. **No role-scoped tooling** — Pi's tool model is flat. There's no concept of "this agent type gets these tools." Role-based tool assignment would need to be built.
-
-4. **"YOLO by design" philosophy** — The maintainer explicitly rejects permission gates, file-scope restrictions, and safety rails. While extensions can add some of these, the architecture and philosophy push against them. Conflict prevention for parallel agents (requirement 6) is particularly at odds with Pi's design.
-
-5. **Subagent implementation is ad-hoc** — The subagent extension spawns child `pi` processes via bash, not in-process agents. This means: separate process overhead, no shared state, limited communication, 50KB output cap. Production-grade subagent management would need significant rework.
-
-6. **No LSP integration** — Building LSP support from scratch is a significant undertaking.
-
-7. **No built-in WebSocket/server mode** — While RPC mode exists, there's no persistent server/API mode that could serve as a dispatch endpoint. The SDK can be embedded, but you'd need to build the server layer.
-
-8. **Node.js-only** — The entire stack is TypeScript/Node.js. If the Dispatch system needs polyglot support, Pi cannot provide it.
-
-#### Summary Verdict
-
-Pi is **not a suitable base framework** for the Dispatch requirements as stated, primarily because it lacks any multi-agent architecture. It would require building the entire hierarchy, orchestration, routing, and role systems from scratch. What Pi does provide (session management, provider abstraction, extension system, state persistence, messaging) are valuable **components that could be used within** a Dispatch-like system, but Pi itself is the wrong substrate.
-
-A better approach would be to use `@earendil-works/pi-ai` and `@earendil-works/pi-agent-core` as **libraries** in a custom-built orchestration system, rather than trying to extend the Pi CLI into something it was never designed to be.
-
----
-
-## Source list
-
-| # | Source | Type |
-|---|--------|------|
-| 1 | [pi.dev website](https://pi.dev) | Official website |
-| 2 | [GitHub monorepo](https://github.com/earendil-works/pi) | Source code |
-| 3 | [Coding Agent README](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) | Official docs |
-| 4 | [Extensions documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md) | Official docs |
-| 5 | [Skills documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md) | Official docs |
-| 6 | [SDK documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/sdk.md) | Official docs |
-| 7 | [Subagent extension example](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/subagent/README.md) | Example code |
-| 8 | [Maintainer's blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/) | Blog post |
-| 9 | [package.json (coding-agent)](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/package.json) | Source metadata |
-| 10 | [package.json (agent-core)](https://github.com/earendil-works/pi/blob/main/packages/agent/package.json) | Source metadata |
-| 11 | [Permission gate extension example](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/permission-gate.ts) | Example code |
-| 12 | [Subagent extension source](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/subagent/index.ts) | Example code |
-
----
-
-## Verbatim quotes
-
-- "Pi is a minimal terminal coding harness. Adapt Pi to your workflows, not the other way around." — [pi.dev](https://pi.dev)
-- "Pi ships with powerful defaults but skips features like sub-agents and plan mode. Ask Pi to build what you want, or install a package that does it your way." — [pi.dev](https://pi.dev)
-- "No sub-agents. There's many ways to do this. Spawn pi instances via tmux, or build your own with extensions, or install a package that does it your way." — [Coding Agent README](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)
-- "If I don't need it, it won't be built. And I don't need a lot of things." — [Mario Zechner's blog](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-- "pi runs in full YOLO mode and assumes you know what you're doing. It has unrestricted access to your filesystem and can execute any command without permission checks or safety rails." — [Mario Zechner's blog](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-- "Spawning multiple sub-agents to implement various features in parallel is an anti-pattern in my book and doesn't work, unless you don't care if your codebase devolves into a pile of garbage." — [Mario Zechner's blog](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-- "Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more." — [Extensions documentation](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md)
-- "Pi does not and will not have a built-in plan mode." — [Mario Zechner's blog](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-- "pi's system prompt and tool definitions together come in below 1000 tokens." — [Mario Zechner's blog](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-- "Submit messages while the agent works. Enter sends a steering message (delivered after current tool, interrupts remaining tools). Alt+Enter sends a follow-up (waits until the agent finishes)." — [pi.dev](https://pi.dev)
-- "I prefer Claude Code for most of my work... Over the past few months, Claude Code has turned into a spaceship with 80% of functionality I have no use for." — [Mario Zechner's blog](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/)
-
----
-
-## Source quality flags
-
-- Source 6 (Maintainer's blog): **Personal blog post** — strong authority on Pi's design philosophy and rationale, but represents one person's opinion. Explicitly states design decisions that may not align with all use cases. The benchmark claims are specific to Pi and useful for comparison but should be taken as one data point.
-- Source 1 (pi.dev): **Marketing website** — the landing page is promotional, but the actual content is technically accurate and links to verifiable source code. Not marketing hype in the traditional sense, but does emphasize features positively.
-- No AI-generated summaries or paid content were used.
-
----
-
-## Confidence: High
-
-Comprehensive primary source data was available: the full source code (GitHub), extensive documentation (extensions.md at 94KB, sdk.md at 32KB), the maintainer's detailed technical blog post, and the package.json metadata. The project is transparent about what it does and doesn't do. No conflicting information was found across sources.
-
----
-
-## Gaps and open questions
-
-1. **Real-world multi-agent usage**: No evidence was found of anyone successfully building a production multi-agent orchestration layer on top of Pi. The subagent extension is explicitly labeled as an example/demo. It's unknown how well it holds up under production loads.
-
-2. **Performance with deeply hierarchical systems**: Since Pi's architecture is single-agent, there's no data on how it performs when one Pi instance orchestrates many child Pi instances. The subagent example caps at 8 tasks/4 concurrent.
-
-3. **LSP integration**: No community extensions or discussions about LSP support were found. The feasibility of building an LSP extension is theoretical.
-
-4. **Conflict prevention approaches**: While Pi's philosophy rejects permissions, there may be creative approaches using containers, directory-restricted `pi` instances, or RPC-level routing that were not explored in this research.
-
-5. **Community ecosystem size**: The Discord server exists and packages are listed on npm, but no hard data was found on how many third-party extensions/packages exist or how active the community is beyond the core maintainer.
-
-6. **Comparison with opencode**: The maintainer mentions opencode in his blog (using their models.dev data), and Pi was partly inspired by frustrations with Claude Code. But no direct architectural comparison with opencode was found — this would be valuable for evaluating which framework is a better base for the Dispatch requirements.
diff --git a/tsconfig.base.json b/tsconfig.base.json
deleted file mode 100644
index a0b8514..0000000
--- a/tsconfig.base.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "compilerOptions": {
- "strict": true,
- "target": "ESNext",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "esModuleInterop": true,
- "skipLibCheck": true,
- "forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "declaration": true,
- "declarationMap": true,
- "sourceMap": true,
- "noUncheckedIndexedAccess": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "verbatimModuleSyntax": true
- }
-}
diff --git a/vitest.config.ts b/vitest.config.ts
deleted file mode 100644
index e74aab6..0000000
--- a/vitest.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- projects: ["packages/*"],
- },
-});