From 394f1ed37ce860da6fdc385769bf29f9737105cd Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 4 Jun 2026 21:21:20 +0900 Subject: chore: genesis — remove all files to rebuild from scratch (arch rewrite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 13 - .env.example | 1 - .gitignore | 34 - CS_ARTIX_DEPLOY.md | 132 -- Dockerfile | 67 - Dockerfile.dev | 74 - HANDOFF.md | 99 - UPSTREAM_CS_FUZZY_BUG.md | 111 - bin/build | 7 - bin/build-frontend | 17 - bin/build-pkg | 28 - bin/build-pkg-electron | 20 - bin/build-pkg-windows | 38 - bin/clean | 9 - bin/copilot-auth | 84 - bin/dev_secrets | 27 - bin/down | 7 - bin/import-credentials.ts | 34 - bin/install-pkg | 76 - bin/prod_secrets | 33 - bin/seed-opencode-keys.ts | 28 - bin/service | 105 - bin/test | 10 - bin/up | 79 - bin/up-backend | 21 - bin/up-frontend | 8 - biome.json | 61 - bun.lock | 1062 --------- cc/01-tool-schema-analysis.md | 62 - cc/02-anthropic-tool-format.md | 90 - cc/03-recommendations.md | 116 - cc/04-schema-debug.md | 40 - cc/README.md | 38 - dispatch.toml | 89 - docker-compose.prod.yml | 17 - docker-compose.yml | 52 - docker/cs/fuzzy-distance.patch | 159 -- docker/cs/luau-declarations.patch | 32 - docker/entrypoint.dev.sh | 71 - docker/entrypoint.sh | 8 - package.json | 20 - packages/api/package.json | 20 - packages/api/src/agent-manager.ts | 2453 -------------------- packages/api/src/app.ts | 278 --- packages/api/src/index.ts | 127 - packages/api/src/permission-manager.ts | 103 - packages/api/src/routes/agents.ts | 126 - packages/api/src/routes/config.ts | 27 - packages/api/src/routes/models.ts | 1073 --------- packages/api/src/routes/notifications.ts | 88 - packages/api/src/routes/skills.ts | 48 - packages/api/src/routes/tabs.ts | 229 -- packages/api/src/types.ts | 2 - packages/api/src/wake-scheduler.ts | 97 - packages/api/tests/agent-manager.test.ts | 2142 ----------------- packages/api/tests/permission-manager.test.ts | 99 - packages/api/tests/routes.test.ts | 1097 --------- packages/api/tests/wake-scheduler.test.ts | 98 - packages/api/tsconfig.json | 10 - packages/api/vitest.config.ts | 12 - packages/core/package.json | 29 - packages/core/src/agent/agent.ts | Bin 66313 -> 0 bytes packages/core/src/agents/index.ts | 11 - packages/core/src/agents/loader.ts | 294 --- packages/core/src/chunks/append.ts | 314 --- packages/core/src/chunks/transform.ts | 271 --- packages/core/src/compaction/index.ts | 245 -- packages/core/src/config/index.ts | 9 - packages/core/src/config/loader.ts | 226 -- packages/core/src/config/schema.ts | 239 -- packages/core/src/config/watcher.ts | 129 - packages/core/src/credentials/anthropic-betas.ts | 24 - packages/core/src/credentials/api-keys.ts | 80 - packages/core/src/credentials/claude.ts | 694 ------ packages/core/src/credentials/copilot.ts | 64 - packages/core/src/credentials/google.ts | 178 -- packages/core/src/credentials/index.ts | 52 - packages/core/src/credentials/opencode.ts | 126 - packages/core/src/credentials/store.ts | 185 -- packages/core/src/db/chunks.ts | 246 -- packages/core/src/db/index.ts | 177 -- packages/core/src/db/settings.ts | 22 - packages/core/src/db/tabs.ts | 250 -- packages/core/src/index.ts | 172 -- packages/core/src/llm/anthropic-oauth-transform.ts | 153 -- packages/core/src/llm/debug-logger.ts | 448 ---- packages/core/src/llm/provider.ts | 180 -- packages/core/src/lsp/client.ts | 658 ------ packages/core/src/lsp/diagnostic.ts | 41 - packages/core/src/lsp/index.ts | 18 - packages/core/src/lsp/language.ts | 72 - packages/core/src/lsp/manager.ts | 220 -- packages/core/src/lsp/server.ts | 68 - packages/core/src/models/attachments.ts | 151 -- packages/core/src/models/catalog.ts | 229 -- packages/core/src/models/index.ts | 24 - packages/core/src/models/registry.ts | 86 - packages/core/src/notifications/config.ts | 77 - packages/core/src/notifications/dispatcher.ts | 287 --- packages/core/src/notifications/index.ts | 36 - packages/core/src/notifications/ntfy.ts | 149 -- packages/core/src/notifications/types.ts | 108 - packages/core/src/permission/evaluate.ts | 15 - packages/core/src/permission/index.ts | 26 - packages/core/src/permission/service.ts | 76 - packages/core/src/permission/wildcard.ts | 10 - packages/core/src/skills/index.ts | 7 - packages/core/src/skills/loader.ts | 248 -- packages/core/src/skills/parser.ts | 60 - packages/core/src/tools/bash-arity.ts | 48 - packages/core/src/tools/key-usage.ts | 322 --- packages/core/src/tools/list-files.ts | 40 - packages/core/src/tools/lsp.ts | 135 -- packages/core/src/tools/path-utils.ts | 55 - packages/core/src/tools/read-file-slice.ts | 90 - packages/core/src/tools/read-file.ts | 129 - packages/core/src/tools/read-tab.ts | 95 - packages/core/src/tools/registry.ts | 91 - packages/core/src/tools/retrieve.ts | 71 - packages/core/src/tools/run-shell.ts | 176 -- packages/core/src/tools/search-code.ts | 362 --- packages/core/src/tools/send-to-tab.ts | 198 -- packages/core/src/tools/shell-analyze.ts | 166 -- packages/core/src/tools/summon.ts | 447 ---- packages/core/src/tools/task-list.ts | 149 -- packages/core/src/tools/truncate.ts | 142 -- packages/core/src/tools/web-search.ts | 106 - packages/core/src/tools/write-file.ts | 66 - packages/core/src/tools/youtube-transcribe.ts | 219 -- packages/core/src/types/index.ts | 672 ------ packages/core/tests/agent/agent.test.ts | 1791 -------------- packages/core/tests/agents/loader.test.ts | 233 -- packages/core/tests/chunks/append.test.ts | 534 ----- packages/core/tests/compaction/compaction.test.ts | 194 -- packages/core/tests/config/loader.test.ts | 176 -- packages/core/tests/config/lsp-schema.test.ts | 110 - packages/core/tests/config/merge.test.ts | 223 -- packages/core/tests/config/watcher.test.ts | 50 - packages/core/tests/credentials/wake-probe.test.ts | 73 - packages/core/tests/db/chunks.db.test.ts | 336 --- packages/core/tests/db/chunks.test.ts | 179 -- packages/core/tests/db/rekey-chunks.db.test.ts | 129 - packages/core/tests/db/tabs.test.ts | 418 ---- packages/core/tests/fixture/lsp/fake-lsp-server.js | 195 -- .../tests/llm/anthropic-oauth-transform.test.ts | 137 -- packages/core/tests/llm/provider.test.ts | 271 --- packages/core/tests/lsp/client.test.ts | 146 -- packages/core/tests/lsp/diagnostic.test.ts | 67 - packages/core/tests/lsp/luau-lsp.smoke.test.ts | 63 - packages/core/tests/lsp/manager.test.ts | 120 - packages/core/tests/lsp/server.test.ts | 41 - packages/core/tests/models/attachments.test.ts | 136 -- packages/core/tests/models/catalog.test.ts | 227 -- packages/core/tests/notifications/config.test.ts | 158 -- .../core/tests/notifications/dispatcher.test.ts | 461 ---- packages/core/tests/notifications/ntfy.test.ts | 204 -- packages/core/tests/permission/evaluate.test.ts | 68 - packages/core/tests/permission/service.test.ts | 95 - packages/core/tests/permission/wildcard.test.ts | 50 - packages/core/tests/tools/bash-arity.test.ts | 36 - packages/core/tests/tools/key-usage.test.ts | 317 --- packages/core/tests/tools/list-files.test.ts | 94 - packages/core/tests/tools/lsp-tool.test.ts | 110 - packages/core/tests/tools/read-file.test.ts | 142 -- packages/core/tests/tools/read-tab.test.ts | 101 - packages/core/tests/tools/registry.test.ts | 143 -- packages/core/tests/tools/run-shell.test.ts | 78 - packages/core/tests/tools/search-code.test.ts | 511 ---- packages/core/tests/tools/send-to-tab.test.ts | 185 -- packages/core/tests/tools/summon.test.ts | 349 --- packages/core/tests/tools/task-list.test.ts | 158 -- packages/core/tests/tools/write-file.test.ts | 152 -- packages/core/tests/types/reasoning-effort.test.ts | 48 - packages/core/tsconfig.json | 10 - packages/core/vitest.config.ts | 14 - packages/frontend/electron/main.cjs | 33 - packages/frontend/electron/preload.cjs | 7 - packages/frontend/index.html | 12 - packages/frontend/package.json | 54 - packages/frontend/serve.ts | 68 - packages/frontend/src/App.svelte | 347 --- packages/frontend/src/app.css | 111 - packages/frontend/src/lib/attachment-tokens.ts | 234 -- packages/frontend/src/lib/cache-warm-storage.ts | 77 - packages/frontend/src/lib/cache-warming.svelte.ts | 318 --- .../src/lib/components/AgentBuilder.svelte | 806 ------- .../src/lib/components/CacheRatePanel.svelte | 124 - .../frontend/src/lib/components/ChatInput.svelte | 396 ---- .../frontend/src/lib/components/ChatMessage.svelte | 148 -- .../frontend/src/lib/components/ChatPanel.svelte | 181 -- .../frontend/src/lib/components/ClaudeReset.svelte | 375 --- .../frontend/src/lib/components/ConfigPanel.svelte | 251 -- .../src/lib/components/ContextWindowPanel.svelte | 85 - .../frontend/src/lib/components/DebugPanel.svelte | 35 - packages/frontend/src/lib/components/Header.svelte | 27 - .../src/lib/components/HotReloadIndicator.svelte | 35 - .../frontend/src/lib/components/KeyUsage.svelte | 477 ---- .../src/lib/components/MarkdownRenderer.svelte | 174 -- .../src/lib/components/ModelSelector.svelte | 636 ----- .../frontend/src/lib/components/ModelStatus.svelte | 356 --- .../src/lib/components/PermissionPrompt.svelte | 100 - .../src/lib/components/SettingsPanel.svelte | 643 ----- .../src/lib/components/SidebarPanel.svelte | 220 -- .../src/lib/components/SkillsBrowser.svelte | 317 --- .../src/lib/components/SystemPromptPanel.svelte | 61 - packages/frontend/src/lib/components/TabBar.svelte | 234 -- .../src/lib/components/TaskListPanel.svelte | 80 - .../src/lib/components/ToolCallDisplay.svelte | 140 -- .../src/lib/components/ToolPermissions.svelte | 185 -- packages/frontend/src/lib/config.ts | 44 - packages/frontend/src/lib/context-window.ts | 37 - packages/frontend/src/lib/router.svelte.ts | 12 - packages/frontend/src/lib/settings.svelte.ts | 88 - packages/frontend/src/lib/sidebar-storage.ts | 84 - packages/frontend/src/lib/snapshot-sequencer.ts | 47 - packages/frontend/src/lib/tabs.svelte.ts | 2441 ------------------- packages/frontend/src/lib/theme.ts | 92 - packages/frontend/src/lib/types.ts | 337 --- packages/frontend/src/lib/ws.svelte.ts | 127 - packages/frontend/src/main.ts | 9 - packages/frontend/src/vite-env.d.ts | 2 - packages/frontend/svelte.config.js | 5 - packages/frontend/tests/attachment-tokens.test.ts | 130 -- packages/frontend/tests/cache-warm-storage.test.ts | 111 - packages/frontend/tests/cache-warming.test.ts | 232 -- packages/frontend/tests/chat-store.test.ts | 2203 ------------------ packages/frontend/tests/context-window.test.ts | 84 - packages/frontend/tests/sidebar-storage.test.ts | 148 -- packages/frontend/tests/snapshot-sequencer.test.ts | 92 - packages/frontend/tests/theme.test.ts | 144 -- packages/frontend/tsconfig.json | 10 - packages/frontend/vite.config.ts | 23 - packaging/PKGBUILD | 274 --- packaging/dispatch-api-wrapper.sh | 32 - packaging/dispatch-api.conf | 31 - packaging/dispatch-api@.service | 21 - packaging/dispatch-frontend-wrapper.sh | 30 - packaging/dispatch-frontend.conf | 21 - packaging/dispatch-frontend@.service | 22 - packaging/dispatch-s6.install | 46 - packaging/dispatch-systemd.install | 25 - packaging/dispatch.install | 31 - packaging/electron/PKGBUILD | 100 - packaging/electron/dispatch-electron-wrapper.sh | 2 - packaging/electron/dispatch-electron.install | 32 - packaging/electron/dispatch.desktop | 9 - packaging/electron/dispatch.png | Bin 10192 -> 0 bytes packaging/electron/dispatch.svg | 24 - packaging/s6/dispatch-api-log/consumer-for | 1 - packaging/s6/dispatch-api-log/notification-fd | 1 - packaging/s6/dispatch-api-log/pipeline-name | 1 - packaging/s6/dispatch-api-log/run | 6 - packaging/s6/dispatch-api-log/type | 1 - packaging/s6/dispatch-api-srv/producer-for | 1 - packaging/s6/dispatch-api-srv/run | 50 - packaging/s6/dispatch-api-srv/type | 1 - packaging/s6/dispatch-frontend-log/consumer-for | 1 - packaging/s6/dispatch-frontend-log/notification-fd | 1 - packaging/s6/dispatch-frontend-log/pipeline-name | 1 - packaging/s6/dispatch-frontend-log/run | 6 - packaging/s6/dispatch-frontend-log/type | 1 - packaging/s6/dispatch-frontend-srv/producer-for | 1 - packaging/s6/dispatch-frontend-srv/run | 25 - packaging/s6/dispatch-frontend-srv/type | 1 - research/ai-coding-assistants-established.md | 345 --- research/ai-coding-assistants-newer.md | 451 ---- research/emerging-specialized.md | 473 ---- research/general-agent-platforms.md | 299 --- research/multi-agent-orchestration.md | 235 -- research/multi-agent-roleplay.md | 439 ---- research/pi-dev-harness.md | 322 --- tsconfig.base.json | 20 - vitest.config.ts | 7 - 273 files changed, 49043 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env.example delete mode 100644 .gitignore delete mode 100644 CS_ARTIX_DEPLOY.md delete mode 100644 Dockerfile delete mode 100644 Dockerfile.dev delete mode 100644 HANDOFF.md delete mode 100644 UPSTREAM_CS_FUZZY_BUG.md delete mode 100755 bin/build delete mode 100755 bin/build-frontend delete mode 100755 bin/build-pkg delete mode 100755 bin/build-pkg-electron delete mode 100755 bin/build-pkg-windows delete mode 100755 bin/clean delete mode 100755 bin/copilot-auth delete mode 100755 bin/dev_secrets delete mode 100755 bin/down delete mode 100755 bin/import-credentials.ts delete mode 100755 bin/install-pkg delete mode 100755 bin/prod_secrets delete mode 100644 bin/seed-opencode-keys.ts delete mode 100755 bin/service delete mode 100755 bin/test delete mode 100755 bin/up delete mode 100755 bin/up-backend delete mode 100755 bin/up-frontend delete mode 100644 biome.json delete mode 100644 bun.lock delete mode 100644 cc/01-tool-schema-analysis.md delete mode 100644 cc/02-anthropic-tool-format.md delete mode 100644 cc/03-recommendations.md delete mode 100644 cc/04-schema-debug.md delete mode 100644 cc/README.md delete mode 100644 dispatch.toml delete mode 100644 docker-compose.prod.yml delete mode 100644 docker-compose.yml delete mode 100644 docker/cs/fuzzy-distance.patch delete mode 100644 docker/cs/luau-declarations.patch delete mode 100755 docker/entrypoint.dev.sh delete mode 100644 docker/entrypoint.sh delete mode 100644 package.json delete mode 100644 packages/api/package.json delete mode 100644 packages/api/src/agent-manager.ts delete mode 100644 packages/api/src/app.ts delete mode 100644 packages/api/src/index.ts delete mode 100644 packages/api/src/permission-manager.ts delete mode 100644 packages/api/src/routes/agents.ts delete mode 100644 packages/api/src/routes/config.ts delete mode 100644 packages/api/src/routes/models.ts delete mode 100644 packages/api/src/routes/notifications.ts delete mode 100644 packages/api/src/routes/skills.ts delete mode 100644 packages/api/src/routes/tabs.ts delete mode 100644 packages/api/src/types.ts delete mode 100644 packages/api/src/wake-scheduler.ts delete mode 100644 packages/api/tests/agent-manager.test.ts delete mode 100644 packages/api/tests/permission-manager.test.ts delete mode 100644 packages/api/tests/routes.test.ts delete mode 100644 packages/api/tests/wake-scheduler.test.ts delete mode 100644 packages/api/tsconfig.json delete mode 100644 packages/api/vitest.config.ts delete mode 100644 packages/core/package.json delete mode 100644 packages/core/src/agent/agent.ts delete mode 100644 packages/core/src/agents/index.ts delete mode 100644 packages/core/src/agents/loader.ts delete mode 100644 packages/core/src/chunks/append.ts delete mode 100644 packages/core/src/chunks/transform.ts delete mode 100644 packages/core/src/compaction/index.ts delete mode 100644 packages/core/src/config/index.ts delete mode 100644 packages/core/src/config/loader.ts delete mode 100644 packages/core/src/config/schema.ts delete mode 100644 packages/core/src/config/watcher.ts delete mode 100644 packages/core/src/credentials/anthropic-betas.ts delete mode 100644 packages/core/src/credentials/api-keys.ts delete mode 100644 packages/core/src/credentials/claude.ts delete mode 100644 packages/core/src/credentials/copilot.ts delete mode 100644 packages/core/src/credentials/google.ts delete mode 100644 packages/core/src/credentials/index.ts delete mode 100644 packages/core/src/credentials/opencode.ts delete mode 100644 packages/core/src/credentials/store.ts delete mode 100644 packages/core/src/db/chunks.ts delete mode 100644 packages/core/src/db/index.ts delete mode 100644 packages/core/src/db/settings.ts delete mode 100644 packages/core/src/db/tabs.ts delete mode 100644 packages/core/src/index.ts delete mode 100644 packages/core/src/llm/anthropic-oauth-transform.ts delete mode 100644 packages/core/src/llm/debug-logger.ts delete mode 100644 packages/core/src/llm/provider.ts delete mode 100644 packages/core/src/lsp/client.ts delete mode 100644 packages/core/src/lsp/diagnostic.ts delete mode 100644 packages/core/src/lsp/index.ts delete mode 100644 packages/core/src/lsp/language.ts delete mode 100644 packages/core/src/lsp/manager.ts delete mode 100644 packages/core/src/lsp/server.ts delete mode 100644 packages/core/src/models/attachments.ts delete mode 100644 packages/core/src/models/catalog.ts delete mode 100644 packages/core/src/models/index.ts delete mode 100644 packages/core/src/models/registry.ts delete mode 100644 packages/core/src/notifications/config.ts delete mode 100644 packages/core/src/notifications/dispatcher.ts delete mode 100644 packages/core/src/notifications/index.ts delete mode 100644 packages/core/src/notifications/ntfy.ts delete mode 100644 packages/core/src/notifications/types.ts delete mode 100644 packages/core/src/permission/evaluate.ts delete mode 100644 packages/core/src/permission/index.ts delete mode 100644 packages/core/src/permission/service.ts delete mode 100644 packages/core/src/permission/wildcard.ts delete mode 100644 packages/core/src/skills/index.ts delete mode 100644 packages/core/src/skills/loader.ts delete mode 100644 packages/core/src/skills/parser.ts delete mode 100644 packages/core/src/tools/bash-arity.ts delete mode 100644 packages/core/src/tools/key-usage.ts delete mode 100644 packages/core/src/tools/list-files.ts delete mode 100644 packages/core/src/tools/lsp.ts delete mode 100644 packages/core/src/tools/path-utils.ts delete mode 100644 packages/core/src/tools/read-file-slice.ts delete mode 100644 packages/core/src/tools/read-file.ts delete mode 100644 packages/core/src/tools/read-tab.ts delete mode 100644 packages/core/src/tools/registry.ts delete mode 100644 packages/core/src/tools/retrieve.ts delete mode 100644 packages/core/src/tools/run-shell.ts delete mode 100644 packages/core/src/tools/search-code.ts delete mode 100644 packages/core/src/tools/send-to-tab.ts delete mode 100644 packages/core/src/tools/shell-analyze.ts delete mode 100644 packages/core/src/tools/summon.ts delete mode 100644 packages/core/src/tools/task-list.ts delete mode 100644 packages/core/src/tools/truncate.ts delete mode 100644 packages/core/src/tools/web-search.ts delete mode 100644 packages/core/src/tools/write-file.ts delete mode 100644 packages/core/src/tools/youtube-transcribe.ts delete mode 100644 packages/core/src/types/index.ts delete mode 100644 packages/core/tests/agent/agent.test.ts delete mode 100644 packages/core/tests/agents/loader.test.ts delete mode 100644 packages/core/tests/chunks/append.test.ts delete mode 100644 packages/core/tests/compaction/compaction.test.ts delete mode 100644 packages/core/tests/config/loader.test.ts delete mode 100644 packages/core/tests/config/lsp-schema.test.ts delete mode 100644 packages/core/tests/config/merge.test.ts delete mode 100644 packages/core/tests/config/watcher.test.ts delete mode 100644 packages/core/tests/credentials/wake-probe.test.ts delete mode 100644 packages/core/tests/db/chunks.db.test.ts delete mode 100644 packages/core/tests/db/chunks.test.ts delete mode 100644 packages/core/tests/db/rekey-chunks.db.test.ts delete mode 100644 packages/core/tests/db/tabs.test.ts delete mode 100644 packages/core/tests/fixture/lsp/fake-lsp-server.js delete mode 100644 packages/core/tests/llm/anthropic-oauth-transform.test.ts delete mode 100644 packages/core/tests/llm/provider.test.ts delete mode 100644 packages/core/tests/lsp/client.test.ts delete mode 100644 packages/core/tests/lsp/diagnostic.test.ts delete mode 100644 packages/core/tests/lsp/luau-lsp.smoke.test.ts delete mode 100644 packages/core/tests/lsp/manager.test.ts delete mode 100644 packages/core/tests/lsp/server.test.ts delete mode 100644 packages/core/tests/models/attachments.test.ts delete mode 100644 packages/core/tests/models/catalog.test.ts delete mode 100644 packages/core/tests/notifications/config.test.ts delete mode 100644 packages/core/tests/notifications/dispatcher.test.ts delete mode 100644 packages/core/tests/notifications/ntfy.test.ts delete mode 100644 packages/core/tests/permission/evaluate.test.ts delete mode 100644 packages/core/tests/permission/service.test.ts delete mode 100644 packages/core/tests/permission/wildcard.test.ts delete mode 100644 packages/core/tests/tools/bash-arity.test.ts delete mode 100644 packages/core/tests/tools/key-usage.test.ts delete mode 100644 packages/core/tests/tools/list-files.test.ts delete mode 100644 packages/core/tests/tools/lsp-tool.test.ts delete mode 100644 packages/core/tests/tools/read-file.test.ts delete mode 100644 packages/core/tests/tools/read-tab.test.ts delete mode 100644 packages/core/tests/tools/registry.test.ts delete mode 100644 packages/core/tests/tools/run-shell.test.ts delete mode 100644 packages/core/tests/tools/search-code.test.ts delete mode 100644 packages/core/tests/tools/send-to-tab.test.ts delete mode 100644 packages/core/tests/tools/summon.test.ts delete mode 100644 packages/core/tests/tools/task-list.test.ts delete mode 100644 packages/core/tests/tools/write-file.test.ts delete mode 100644 packages/core/tests/types/reasoning-effort.test.ts delete mode 100644 packages/core/tsconfig.json delete mode 100644 packages/core/vitest.config.ts delete mode 100644 packages/frontend/electron/main.cjs delete mode 100644 packages/frontend/electron/preload.cjs delete mode 100644 packages/frontend/index.html delete mode 100644 packages/frontend/package.json delete mode 100644 packages/frontend/serve.ts delete mode 100644 packages/frontend/src/App.svelte delete mode 100644 packages/frontend/src/app.css delete mode 100644 packages/frontend/src/lib/attachment-tokens.ts delete mode 100644 packages/frontend/src/lib/cache-warm-storage.ts delete mode 100644 packages/frontend/src/lib/cache-warming.svelte.ts delete mode 100644 packages/frontend/src/lib/components/AgentBuilder.svelte delete mode 100644 packages/frontend/src/lib/components/CacheRatePanel.svelte delete mode 100644 packages/frontend/src/lib/components/ChatInput.svelte delete mode 100644 packages/frontend/src/lib/components/ChatMessage.svelte delete mode 100644 packages/frontend/src/lib/components/ChatPanel.svelte delete mode 100644 packages/frontend/src/lib/components/ClaudeReset.svelte delete mode 100644 packages/frontend/src/lib/components/ConfigPanel.svelte delete mode 100644 packages/frontend/src/lib/components/ContextWindowPanel.svelte delete mode 100644 packages/frontend/src/lib/components/DebugPanel.svelte delete mode 100644 packages/frontend/src/lib/components/Header.svelte delete mode 100644 packages/frontend/src/lib/components/HotReloadIndicator.svelte delete mode 100644 packages/frontend/src/lib/components/KeyUsage.svelte delete mode 100644 packages/frontend/src/lib/components/MarkdownRenderer.svelte delete mode 100644 packages/frontend/src/lib/components/ModelSelector.svelte delete mode 100644 packages/frontend/src/lib/components/ModelStatus.svelte delete mode 100644 packages/frontend/src/lib/components/PermissionPrompt.svelte delete mode 100644 packages/frontend/src/lib/components/SettingsPanel.svelte delete mode 100644 packages/frontend/src/lib/components/SidebarPanel.svelte delete mode 100644 packages/frontend/src/lib/components/SkillsBrowser.svelte delete mode 100644 packages/frontend/src/lib/components/SystemPromptPanel.svelte delete mode 100644 packages/frontend/src/lib/components/TabBar.svelte delete mode 100644 packages/frontend/src/lib/components/TaskListPanel.svelte delete mode 100644 packages/frontend/src/lib/components/ToolCallDisplay.svelte delete mode 100644 packages/frontend/src/lib/components/ToolPermissions.svelte delete mode 100644 packages/frontend/src/lib/config.ts delete mode 100644 packages/frontend/src/lib/context-window.ts delete mode 100644 packages/frontend/src/lib/router.svelte.ts delete mode 100644 packages/frontend/src/lib/settings.svelte.ts delete mode 100644 packages/frontend/src/lib/sidebar-storage.ts delete mode 100644 packages/frontend/src/lib/snapshot-sequencer.ts delete mode 100644 packages/frontend/src/lib/tabs.svelte.ts delete mode 100644 packages/frontend/src/lib/theme.ts delete mode 100644 packages/frontend/src/lib/types.ts delete mode 100644 packages/frontend/src/lib/ws.svelte.ts delete mode 100644 packages/frontend/src/main.ts delete mode 100644 packages/frontend/src/vite-env.d.ts delete mode 100644 packages/frontend/svelte.config.js delete mode 100644 packages/frontend/tests/attachment-tokens.test.ts delete mode 100644 packages/frontend/tests/cache-warm-storage.test.ts delete mode 100644 packages/frontend/tests/cache-warming.test.ts delete mode 100644 packages/frontend/tests/chat-store.test.ts delete mode 100644 packages/frontend/tests/context-window.test.ts delete mode 100644 packages/frontend/tests/sidebar-storage.test.ts delete mode 100644 packages/frontend/tests/snapshot-sequencer.test.ts delete mode 100644 packages/frontend/tests/theme.test.ts delete mode 100644 packages/frontend/tsconfig.json delete mode 100644 packages/frontend/vite.config.ts delete mode 100644 packaging/PKGBUILD delete mode 100755 packaging/dispatch-api-wrapper.sh delete mode 100644 packaging/dispatch-api.conf delete mode 100644 packaging/dispatch-api@.service delete mode 100755 packaging/dispatch-frontend-wrapper.sh delete mode 100644 packaging/dispatch-frontend.conf delete mode 100644 packaging/dispatch-frontend@.service delete mode 100644 packaging/dispatch-s6.install delete mode 100644 packaging/dispatch-systemd.install delete mode 100644 packaging/dispatch.install delete mode 100644 packaging/electron/PKGBUILD delete mode 100755 packaging/electron/dispatch-electron-wrapper.sh delete mode 100644 packaging/electron/dispatch-electron.install delete mode 100644 packaging/electron/dispatch.desktop delete mode 100644 packaging/electron/dispatch.png delete mode 100644 packaging/electron/dispatch.svg delete mode 100644 packaging/s6/dispatch-api-log/consumer-for delete mode 100644 packaging/s6/dispatch-api-log/notification-fd delete mode 100644 packaging/s6/dispatch-api-log/pipeline-name delete mode 100644 packaging/s6/dispatch-api-log/run delete mode 100644 packaging/s6/dispatch-api-log/type delete mode 100644 packaging/s6/dispatch-api-srv/producer-for delete mode 100644 packaging/s6/dispatch-api-srv/run delete mode 100644 packaging/s6/dispatch-api-srv/type delete mode 100644 packaging/s6/dispatch-frontend-log/consumer-for delete mode 100644 packaging/s6/dispatch-frontend-log/notification-fd delete mode 100644 packaging/s6/dispatch-frontend-log/pipeline-name delete mode 100644 packaging/s6/dispatch-frontend-log/run delete mode 100644 packaging/s6/dispatch-frontend-log/type delete mode 100644 packaging/s6/dispatch-frontend-srv/producer-for delete mode 100644 packaging/s6/dispatch-frontend-srv/run delete mode 100644 packaging/s6/dispatch-frontend-srv/type delete mode 100644 research/ai-coding-assistants-established.md delete mode 100644 research/ai-coding-assistants-newer.md delete mode 100644 research/emerging-specialized.md delete mode 100644 research/general-agent-platforms.md delete mode 100644 research/multi-agent-orchestration.md delete mode 100644 research/multi-agent-roleplay.md delete mode 100644 research/pi-dev-harness.md delete mode 100644 tsconfig.base.json delete mode 100644 vitest.config.ts 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 < - -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 dispatch-api@.service &>/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/ -> the project bind-mount, host path $PROJECT_DIR/ -# (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": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], - - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.79", "", { "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/gateway@3.0.120", "", { "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/openai-compatible@2.0.48", "", { "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/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "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/biome@2.4.15", "", { "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/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], - - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="], - - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="], - - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], - - "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "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/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], - - "@electron/fuses": ["@electron/fuses@1.8.0", "", { "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/get@2.0.3", "", { "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/notarize@2.5.0", "", { "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/osx-sign@1.3.3", "", { "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/rebuild@4.0.4", "", { "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/universal@2.0.3", "", { "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/windows-sign@1.2.2", "", { "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/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], - - "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "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/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], - - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], - - "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "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/vite-plugin-svelte-inspector@4.0.1", "", { "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/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "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/oxide@4.3.0", "", { "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/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "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/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "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/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], - - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - - "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - - "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], - - "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/node": ["@types/node@25.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="], - - "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], - - "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], - - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - - "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], - - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "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/mocker@3.2.4", "", { "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/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], - - "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], - - "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], - - "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], - - "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], - - "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], - - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "ai": ["ai@6.0.191", "", { "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": ["ajv@6.15.0", "", { "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": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], - - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], - - "app-builder-lib": ["app-builder-lib@26.8.1", "", { "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": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], - - "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], - - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - - "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], - - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - - "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], - - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], - - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - - "builder-util": ["builder-util@26.8.1", "", { "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": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - - "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], - - "cacheable-request": ["cacheable-request@7.0.4", "", { "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": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "chai": ["chai@5.3.3", "", { "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": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - - "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], - - "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - - "cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], - - "cliui": ["cliui@8.0.1", "", { "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": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - - "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], - - "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], - - "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], - - "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "daisyui": ["daisyui@5.5.20", "", {}, "sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - - "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "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": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - - "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], - - "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], - - "dmg-builder": ["dmg-builder@26.8.1", "", { "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": ["dmg-license@1.0.11", "", { "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": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], - - "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - - "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - - "electron": ["electron@35.7.5", "", { "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": ["electron-builder@26.8.1", "", { "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": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], - - "electron-publish": ["electron-publish@26.8.1", "", { "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": ["electron-winstaller@5.4.0", "", { "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": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - - "enhanced-resolve": ["enhanced-resolve@5.21.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-wE4fDO8OjJhrPFH69HUQStq5oKvGRTNXEyW+k5C/pUQLASSsTu7obd2V3GvCDgPcY9AWjhJ4jz9Kh7iRvrxhJg=="], - - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "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": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - - "esbuild": ["esbuild@0.25.12", "", { "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": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], - - "esrap": ["esrap@2.2.9", "", { "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": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - - "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - - "extract-zip": ["extract-zip@2.0.1", "", { "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": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], - - "form-data": ["form-data@4.0.5", "", { "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": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - - "glob": ["glob@7.2.3", "", { "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": ["global-agent@3.0.0", "", { "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": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "got": ["got@11.8.6", "", { "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": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - - "hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="], - - "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], - - "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - - "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], - - "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], - - "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - - "jake": ["jake@10.9.4", "", { "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": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - - "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], - - "lightningcss": ["lightningcss@1.32.0", "", { "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": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - - "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], - - "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], - - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - - "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], - - "marked-highlight": ["marked-highlight@2.2.4", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="], - - "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - - "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "node-abi": ["node-abi@4.31.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw=="], - - "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], - - "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], - - "node-gyp": ["node-gyp@12.3.0", "", { "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": ["node-gyp-build@4.8.4", "", { "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": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], - - "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - - "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], - - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - - "phosphor-svelte": ["phosphor-svelte@3.1.0", "", { "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": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - - "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], - - "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], - - "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - - "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], - - "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - - "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], - - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], - - "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], - - "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], - - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - - "rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], - - "roarr": ["roarr@2.15.4", "", { "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": ["rollup@4.60.4", "", { "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": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], - - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], - - "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], - - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], - - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], - - "string-width": ["string-width@4.2.3", "", { "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": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], - - "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "svelte": ["svelte@5.55.8", "", { "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": ["svelte-check@4.4.8", "", { "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": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], - - "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - - "tar": ["tar@7.5.15", "", { "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": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], - - "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], - - "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], - - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - - "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], - - "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], - - "tree-sitter-bash": ["tree-sitter-bash@0.25.1", "", { "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": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], - - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], - - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], - - "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], - - "vite": ["vite@6.4.2", "", { "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": ["vite-node@3.2.4", "", { "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": ["vitefu@1.1.3", "", { "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": ["vitest@3.2.4", "", { "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": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "web-tree-sitter": ["web-tree-sitter@0.26.8", "", {}, "sha512-4sUwi7ZyOrIk5KLgYLkc2A/F0LFMQnBhfb+2Cdl7ik4ePJ6JD+fk4ofI2sA5eGawBKBaK4Vntt7Ww5KcEsay4A=="], - - "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], - - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - - "yargs": ["yargs@17.7.2", "", { "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": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - - "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "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": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "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": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], - - "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - - "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - - "@electron/windows-sign/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - - "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "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/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "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/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "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": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], - - "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "electron/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], - - "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - - "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - - "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - - "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "node-api-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - - "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], - - "simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - - "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "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": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - - "svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "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): Record { - // 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)) { - const prop = (schema.properties as Record)[key] as Record; - 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.] 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 = { - 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 = 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 = 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 = 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.]` - * 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 `/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) | undefined { - if (servers.length === 0) return undefined; - const manager = this.lspManager; - return async (absolutePath: string): Promise => { - 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 { - 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 }> = []; - - 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(); - 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 { - 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 { - 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 { - const result: Record = {}; - 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; - 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 { - 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 = 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 { - 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 }> { - 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 { - 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; 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((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; - 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; - 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; -}) => void; - -export class PermissionManager { - private service = new PermissionService(); - private wsClients: Map void> = new Map(); - private promptAddedListeners: Set = new Set(); - /** Ids that have already been broadcast as "added" — guards against re-emits. */ - private announcedPromptIds: Set = 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 { - 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(); - // 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 = { - 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 = { - 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 { - 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(); - 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>>; - -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)[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 { - 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 { - // 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 { - // 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)[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)[timerKey]; - if (prev != null) clearTimeout(prev as ReturnType); - // 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> = {}; - for (const slot of PROBE_SLOT_MINUTES) { - const raw = (timestamps as Record)[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 & { 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; -} | null = () => null; - -export function setTabsAgentManager( - getter: () => { - stopTab(id: string): void; - deleteTab(id: string): void; - compactTab(tempTabId: string, sourceTabId: string): Promise; - } | 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(); -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(); -function resetFakeTabs(): void { - fakeTabs.clear(); -} -function setFakeTab(row: Partial & { 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(); -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>(); -// 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(); -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(); -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; -let runImpl: RunGen | null = null; -function setRunImpl(impl: RunGen | null): void { - runImpl = impl; -} -async function* defaultRun(_message: string): AsyncGenerator { - yield { type: "status", status: "running" } as const; - await new Promise((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 { - // 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; - 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 - | 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` 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((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>; - }; - // 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; - }; - 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; - }; - 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>; - }; - 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((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; - }; - 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((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((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((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; - }; - 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((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; - }; - 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): Promise { - 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): Promise { - 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): Promise { - 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): Promise { - 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): Promise { - 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 - | 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 }>; - 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((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((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((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((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((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; -} - -function makeRequest(overrides: Partial = {}): 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(); - -// 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((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((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((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 }; - 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>; - 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) { - const withAction: Record = - "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 { - 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>; - }; - 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>; - }; - 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 = { - "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>; - }; - 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 = { - "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 }; - 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>; - }; - 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 }; - 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>; - }; - // 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 }; - 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 Binary files a/packages/core/src/agent/agent.ts and /dev/null 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(); - 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 = { - 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).effort; - models.push({ - key_id: String((m as Record).key_id), - model_id: String((m as Record).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( - 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 } | 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